mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-08-17 09:39:28 +02:00
Compare commits
35 Commits
4c06eb1cba
...
2.47.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1181a35f08 | |||
| 4a62f9c34f | |||
| b6e1f92989 | |||
| 0d3d2ddcf0 | |||
| 9afecd47bd | |||
| 5cec162b4e | |||
| 6647f90ced | |||
| b9a3001c11 | |||
| dadfe5b8ef | |||
| 4f29fa63d0 | |||
| 54b0174bde | |||
| 3be5bc6a21 | |||
| c9f152773a | |||
| 5e39611395 | |||
| 40491bdc0f | |||
| 03dd272eaf | |||
| 98e294f7e4 | |||
| b22e786449 | |||
| 951c1e4613 | |||
| 675d910ea3 | |||
| 28f26e45fb | |||
| 4674c517e3 | |||
| b660edfe78 | |||
| 532470e3db | |||
| b533e08d74 | |||
| d490fd0a38 | |||
| 21c2398e2a | |||
| 7ff2347df1 | |||
| 3391e755fc | |||
| f0e9d3be1b | |||
| eef0fc742d | |||
| 2e47c73388 | |||
| 2d49146c74 | |||
| c59374598a | |||
| 01d0203445 |
+2
-2
@@ -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.
|
- `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.
|
- `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 `master` 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 `prime` 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).
|
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:/out` in Bash
|
||||||
- `-v $pwd.Path:/out` in PowerShell
|
- `-v $pwd.Path:/out` in PowerShell
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md).
|
To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md).
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||||
|
|
||||||
TOKEN=tokenhere
|
TOKEN=tokenhere
|
||||||
CHANNELID=channelhere
|
CHANNELID=channelhere
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||||
|
|
||||||
TOKEN=tokenhere
|
TOKEN=tokenhere
|
||||||
CHANNELID=channelhere
|
CHANNELID=channelhere
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
1. Open a text editor such as Notepad and paste:
|
1. Open a text editor such as Notepad and paste:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||||
|
|
||||||
$TOKEN = "tokenhere"
|
$TOKEN = "tokenhere"
|
||||||
$CHANNEL = "channelhere"
|
$CHANNEL = "channelhere"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
3. Type
|
3. Type
|
||||||
|
|
||||||
```js
|
```js
|
||||||
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());
|
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());
|
||||||
```
|
```
|
||||||
|
|
||||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||||
@@ -120,7 +120,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
1. Type
|
1. Type
|
||||||
|
|
||||||
```js
|
```js
|
||||||
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());
|
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());
|
||||||
```
|
```
|
||||||
|
|
||||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||||
|
|||||||
+11
-12
@@ -29,21 +29,20 @@ Type the following command in your terminal of choice, then press ENTER to run i
|
|||||||
> **Note**:
|
> **Note**:
|
||||||
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.
|
> 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).
|
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
|
||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|-------------------------|------------------------------------------------------|
|
| ----------- | ---------------------------------------------------- |
|
||||||
| export | Exports a channel |
|
| export | Exports a channel |
|
||||||
| exportdm | Exports all direct message channels |
|
| exportdm | Exports all direct message channels |
|
||||||
| exportguild | Exports all channels within the specified server |
|
| exportguild | Exports all channels within the specified server |
|
||||||
| exportall | Exports all accessible channels |
|
| exportall | Exports all accessible channels |
|
||||||
| channels | Outputs the list of channels in the given server |
|
| channels | Outputs the list of channels in the given server |
|
||||||
| dm | Outputs the list of direct message channels |
|
| dm | Outputs the list of direct message channels |
|
||||||
| guilds | Outputs the list of accessible servers |
|
| guilds | Outputs the list of accessible servers |
|
||||||
| guide | Explains how to obtain token, server, and channel ID |
|
| 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 `./DiscordChatExporter.Cli guide`.
|
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`.
|
||||||
|
|
||||||
@@ -222,7 +221,7 @@ Use `--filter` to filter what messages are included in the export.
|
|||||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --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/master/.docs/Message-filters.md).
|
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs/Message-filters.md).
|
||||||
|
|
||||||
### Export channels from a specific server
|
### Export channels from a specific server
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: ⚠ Feature request
|
- name: ⚠ Feature request
|
||||||
url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md
|
url: https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md
|
||||||
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
|
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
|
||||||
- name: 📖 Documentation
|
- name: 📖 Documentation
|
||||||
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||||
about: Find usage guides and frequently asked questions.
|
about: Find usage guides and frequently asked questions.
|
||||||
- name: 🗨 Discussions
|
- name: 🗨 Discussions
|
||||||
url: https://github.com/Tyrrrz/DiscordChatExporter/discussions/new
|
url: https://github.com/Tyrrrz/DiscordChatExporter/discussions/new
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- prime
|
||||||
tags:
|
tags:
|
||||||
- "*"
|
- "*"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- prime
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
|
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
|
||||||
@@ -37,15 +37,15 @@ jobs:
|
|||||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||||
|
|
||||||
- name: Upload image
|
- name: Upload image
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: DiscordChatExporter.Cli.Docker
|
name: DiscordChatExporter.Cli.Docker
|
||||||
path: DiscordChatExporter.Cli.Docker.tar
|
path: DiscordChatExporter.Cli.Docker.tar
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
# Deploy to DockerHub only on tag push or master branch push
|
# Deploy to DockerHub only on tag push or prime branch push
|
||||||
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'master' }}
|
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'prime' }}
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- prime
|
||||||
tags:
|
tags:
|
||||||
- "*"
|
- "*"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- prime
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
||||||
@@ -127,6 +127,7 @@ jobs:
|
|||||||
dotnet publish ${{ matrix.app }}
|
dotnet publish ${{ matrix.app }}
|
||||||
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||||
-p:CSharpier_Bypass=true
|
-p:CSharpier_Bypass=true
|
||||||
|
-p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
|
||||||
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
||||||
--output ${{ matrix.app }}/bin/publish/
|
--output ${{ matrix.app }}/bin/publish/
|
||||||
--configuration Release
|
--configuration Release
|
||||||
@@ -134,7 +135,7 @@ jobs:
|
|||||||
--self-contained
|
--self-contained
|
||||||
|
|
||||||
- name: Upload app binaries
|
- name: Upload app binaries
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/bin/publish/
|
path: ${{ matrix.app }}/bin/publish/
|
||||||
@@ -199,7 +200,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download app binaries
|
- name: Download app binaries
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/
|
path: ${{ matrix.app }}/
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="AngleSharp" Version="1.4.0" />
|
||||||
|
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.6.0" />
|
||||||
|
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
|
||||||
|
<PackageVersion Include="Avalonia" Version="11.3.12" />
|
||||||
|
<PackageVersion Include="Avalonia.Desktop" Version="11.3.12" />
|
||||||
|
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.12" />
|
||||||
|
<PackageVersion Include="CliFx" Version="2.3.6" />
|
||||||
|
<PackageVersion Include="Cogwheel" Version="2.1.0" />
|
||||||
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
|
||||||
|
<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.10.4" />
|
||||||
|
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
|
||||||
|
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />
|
||||||
|
<PackageVersion Include="Gress" Version="2.1.1" />
|
||||||
|
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
|
||||||
|
<PackageVersion Include="Markdig" Version="1.0.1" />
|
||||||
|
<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.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||||
|
<PackageVersion Include="Onova" Version="2.6.13" />
|
||||||
|
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||||
|
<PackageVersion Include="RazorBlade" Version="0.11.0" />
|
||||||
|
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
||||||
|
<PackageVersion Include="Superpower" Version="3.1.0" />
|
||||||
|
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.2" />
|
||||||
|
<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,21 +10,18 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
<PackageReference Include="AngleSharp" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
<PackageReference Include="FluentAssertions" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.1" PrivateAssets="all" />
|
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
<PackageReference
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||||
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||||
Version="10.0.2"
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
/>
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
|
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public static class ChannelIds
|
|||||||
|
|
||||||
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||||
|
|
||||||
|
public static Snowflake ForwardTestCases { get; } = Snowflake.Parse("1455202357204877477");
|
||||||
|
|
||||||
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
|
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
|
||||||
|
|
||||||
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using AngleSharp.Dom;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class HtmlForwardSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.ForwardTestCases,
|
||||||
|
Snowflake.Parse("1455202427115536514")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message
|
||||||
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.ContainAll("Forwarded", @"¯\_(ツ)_/¯", "12/29/2025 2:14 PM");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class JsonForwardSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.ForwardTestCases,
|
||||||
|
Snowflake.Parse("1455202427115536514")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var reference = message.GetProperty("reference");
|
||||||
|
reference.GetProperty("type").GetString().Should().Be("Forward");
|
||||||
|
reference.GetProperty("guildId").GetString().Should().Be("869237470565392384");
|
||||||
|
|
||||||
|
var forwardedMessage = message.GetProperty("forwardedMessage");
|
||||||
|
forwardedMessage.GetProperty("content").GetString().Should().Contain(@"¯\_(ツ)_/¯");
|
||||||
|
forwardedMessage
|
||||||
|
.GetProperty("timestamp")
|
||||||
|
.GetString()
|
||||||
|
.Should()
|
||||||
|
.StartWith("2025-12-28T22:52:42.175+00:00");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class PlainTextForwardSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.ForwardTestCases);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
document
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.ContainAll("{Forwarded Message}", @"¯\_(ツ)_/¯", "12/28/2025 10:52 PM");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ WORKDIR /tmp/app
|
|||||||
COPY favicon.ico .
|
COPY favicon.ico .
|
||||||
COPY NuGet.config .
|
COPY NuGet.config .
|
||||||
COPY Directory.Build.props .
|
COPY Directory.Build.props .
|
||||||
|
COPY Directory.Packages.props .
|
||||||
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
||||||
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public class GuideCommand : ICommand
|
|||||||
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
||||||
{
|
{
|
||||||
console.Output.WriteLine(
|
console.Output.WriteLine(
|
||||||
"https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
|
"https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="2.3.6" />
|
<PackageReference Include="CliFx" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="Spectre.Console" Version="0.54.0" />
|
<PackageReference Include="Spectre.Console" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -27,9 +27,16 @@ public partial record Message(
|
|||||||
IReadOnlyList<User> MentionedUsers,
|
IReadOnlyList<User> MentionedUsers,
|
||||||
MessageReference? Reference,
|
MessageReference? Reference,
|
||||||
Message? ReferencedMessage,
|
Message? ReferencedMessage,
|
||||||
|
MessageSnapshot? ForwardedMessage,
|
||||||
Interaction? Interaction
|
Interaction? Interaction
|
||||||
) : IHasId
|
) : IHasId
|
||||||
{
|
{
|
||||||
|
public bool IsEmpty { get; } =
|
||||||
|
string.IsNullOrWhiteSpace(Content)
|
||||||
|
&& !Attachments.Any()
|
||||||
|
&& !Embeds.Any()
|
||||||
|
&& !Stickers.Any();
|
||||||
|
|
||||||
public bool IsSystemNotification { get; } =
|
public bool IsSystemNotification { get; } =
|
||||||
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
||||||
|
|
||||||
@@ -38,11 +45,7 @@ public partial record Message(
|
|||||||
// App interactions are rendered as replies in the Discord client, but they are not actually replies
|
// App interactions are rendered as replies in the Discord client, but they are not actually replies
|
||||||
public bool IsReplyLike => IsReply || Interaction is not null;
|
public bool IsReplyLike => IsReply || Interaction is not null;
|
||||||
|
|
||||||
public bool IsEmpty { get; } =
|
public bool IsForwarded { get; } = Reference?.Kind == MessageReferenceKind.Forward;
|
||||||
string.IsNullOrWhiteSpace(Content)
|
|
||||||
&& !Attachments.Any()
|
|
||||||
&& !Embeds.Any()
|
|
||||||
&& !Stickers.Any();
|
|
||||||
|
|
||||||
public IEnumerable<User> GetReferencedUsers()
|
public IEnumerable<User> GetReferencedUsers()
|
||||||
{
|
{
|
||||||
@@ -171,7 +174,17 @@ public partial record Message
|
|||||||
|
|
||||||
var messageReference = json.GetPropertyOrNull("message_reference")
|
var messageReference = json.GetPropertyOrNull("message_reference")
|
||||||
?.Pipe(MessageReference.Parse);
|
?.Pipe(MessageReference.Parse);
|
||||||
|
|
||||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
||||||
|
|
||||||
|
// Currently Discord only supports 1 snapshot per forward
|
||||||
|
var forwardedMessage = json.GetPropertyOrNull("message_snapshots")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Select(j => j.GetPropertyOrNull("message"))
|
||||||
|
.WhereNotNull()
|
||||||
|
.Select(MessageSnapshot.Parse)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
||||||
|
|
||||||
return new Message(
|
return new Message(
|
||||||
@@ -191,6 +204,7 @@ public partial record Message
|
|||||||
mentionedUsers,
|
mentionedUsers,
|
||||||
messageReference,
|
messageReference,
|
||||||
referencedMessage,
|
referencedMessage,
|
||||||
|
forwardedMessage,
|
||||||
interaction
|
interaction
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,19 @@ using JsonExtensions.Reading;
|
|||||||
namespace DiscordChatExporter.Core.Discord.Data;
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
||||||
public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId)
|
public record MessageReference(
|
||||||
|
MessageReferenceKind Kind,
|
||||||
|
Snowflake? MessageId,
|
||||||
|
Snowflake? ChannelId,
|
||||||
|
Snowflake? GuildId
|
||||||
|
)
|
||||||
{
|
{
|
||||||
public static MessageReference Parse(JsonElement json)
|
public static MessageReference Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
|
var kind =
|
||||||
|
json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t)
|
||||||
|
?? MessageReferenceKind.Default;
|
||||||
|
|
||||||
var messageId = json.GetPropertyOrNull("message_id")
|
var messageId = json.GetPropertyOrNull("message_id")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse);
|
?.Pipe(Snowflake.Parse);
|
||||||
@@ -21,6 +30,6 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
|
|||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse);
|
?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
return new MessageReference(messageId, channelId, guildId);
|
return new MessageReference(kind, messageId, channelId, guildId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#message-reference-types
|
||||||
|
public enum MessageReferenceKind
|
||||||
|
{
|
||||||
|
Default = 0,
|
||||||
|
Forward = 1,
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://docs.discord.com/developers/resources/message#message-snapshot-object
|
||||||
|
public record MessageSnapshot(
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
DateTimeOffset? EditedTimestamp,
|
||||||
|
string Content,
|
||||||
|
IReadOnlyList<Attachment> Attachments,
|
||||||
|
IReadOnlyList<Embed> Embeds,
|
||||||
|
IReadOnlyList<Sticker> Stickers
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static MessageSnapshot Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var timestamp =
|
||||||
|
json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull()
|
||||||
|
?? DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
|
||||||
|
|
||||||
|
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
|
||||||
|
|
||||||
|
var attachments =
|
||||||
|
json.GetPropertyOrNull("attachments")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Select(Attachment.Parse)
|
||||||
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
var embeds =
|
||||||
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
var stickers =
|
||||||
|
json.GetPropertyOrNull("sticker_items")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Select(Sticker.Parse)
|
||||||
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
return new MessageSnapshot(
|
||||||
|
timestamp,
|
||||||
|
editedTimestamp,
|
||||||
|
content,
|
||||||
|
attachments,
|
||||||
|
embeds,
|
||||||
|
stickers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,6 +194,23 @@ public class DiscordClient(
|
|||||||
return Application.Parse(response);
|
return Application.Parse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask EnsureMessageContentIntentAsync(
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (await ResolveTokenKindAsync(cancellationToken) != TokenKind.Bot)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var application = await GetApplicationAsync(cancellationToken);
|
||||||
|
if (application.IsMessageContentIntentEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
throw new DiscordChatExporterException(
|
||||||
|
"Provided bot account is missing the MESSAGE_CONTENT privileged intent.",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask<User?> TryGetUserAsync(
|
public async ValueTask<User?> TryGetUserAsync(
|
||||||
Snowflake userId,
|
Snowflake userId,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -581,7 +598,6 @@ public class DiscordClient(
|
|||||||
private async ValueTask<Message?> TryGetFirstMessageAsync(
|
private async ValueTask<Message?> TryGetFirstMessageAsync(
|
||||||
Snowflake channelId,
|
Snowflake channelId,
|
||||||
Snowflake? after = null,
|
Snowflake? after = null,
|
||||||
Snowflake? before = null,
|
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -594,9 +610,6 @@ public class DiscordClient(
|
|||||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||||
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
||||||
|
|
||||||
if (message is null || before is not null && message.Timestamp > before.Value.ToDate())
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,22 +671,10 @@ public class DiscordClient(
|
|||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
// If all messages are empty, make sure that it's not because the bot account doesn't
|
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||||
// have the Message Content Intent enabled.
|
// have the MESSAGE_CONTENT intent enabled.
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||||
if (
|
if (messages.All(m => m.IsEmpty))
|
||||||
messages.All(m => m.IsEmpty)
|
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||||
&& await ResolveTokenKindAsync(cancellationToken) == TokenKind.Bot
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var application = await GetApplicationAsync(cancellationToken);
|
|
||||||
if (!application.IsMessageContentIntentEnabled)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
"Provided bot account does not have the Message Content Intent enabled.",
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
{
|
||||||
@@ -714,19 +715,16 @@ public class DiscordClient(
|
|||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Get the first message (oldest) in the range to use as the lower bound for
|
// Get the first message in the specified range, so we can later calculate the
|
||||||
// progress calculation.
|
// progress based on the difference between message timestamps.
|
||||||
var firstMessage = await TryGetFirstMessageAsync(
|
// Snapshotting is not necessary here because new messages can't appear in the past.
|
||||||
channelId,
|
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken);
|
||||||
after,
|
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate())
|
||||||
before,
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (firstMessage is null)
|
|
||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
// Keep track of the last message in range in order to calculate the progress
|
// Keep track of the last message in range in order to calculate the progress
|
||||||
var lastMessage = default(Message);
|
var lastMessage = default(Message);
|
||||||
|
|
||||||
var currentBefore = before;
|
var currentBefore = before;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
@@ -745,22 +743,10 @@ public class DiscordClient(
|
|||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
// If all messages are empty, make sure that it's not because the bot account doesn't
|
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||||
// have the Message Content Intent enabled.
|
// have the MESSAGE_CONTENT intent enabled.
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||||
if (
|
if (messages.All(m => m.IsEmpty))
|
||||||
messages.All(m => m.IsEmpty)
|
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||||
&& await ResolveTokenKindAsync(cancellationToken) == TokenKind.Bot
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var application = await GetApplicationAsync(cancellationToken);
|
|
||||||
if (!application.IsMessageContentIntentEnabled)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
"Provided bot account does not have the Message Content Intent enabled.",
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
{
|
||||||
@@ -774,7 +760,11 @@ public class DiscordClient(
|
|||||||
|
|
||||||
progress.Report(
|
progress.Report(
|
||||||
Percentage.FromFraction(
|
Percentage.FromFraction(
|
||||||
totalDuration > TimeSpan.Zero ? exportedDuration / totalDuration : 1
|
// 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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
<PackageReference Include="AngleSharp" />
|
||||||
<PackageReference Include="AsyncKeyedLock" Version="8.0.1" />
|
<PackageReference Include="AsyncKeyedLock" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" />
|
||||||
<PackageReference Include="Polly" Version="8.6.5" />
|
<PackageReference Include="Polly" />
|
||||||
<PackageReference Include="RazorBlade" Version="0.11.0" />
|
<PackageReference Include="RazorBlade" />
|
||||||
<PackageReference Include="Superpower" Version="3.1.0" />
|
<PackageReference Include="Superpower" />
|
||||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
<PackageReference Include="WebMarkupMin.Core" />
|
||||||
<PackageReference Include="YoutubeExplode" Version="6.5.6" />
|
<PackageReference Include="YoutubeExplode" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
|||||||
private readonly HtmlMinifier _minifier = new();
|
private readonly HtmlMinifier _minifier = new();
|
||||||
private readonly List<Message> _messageGroup = [];
|
private readonly List<Message> _messageGroup = [];
|
||||||
|
|
||||||
// Note: in reverse order, last message is earlier than first message
|
// Note: in reverse order, last message appears earlier than the first message
|
||||||
private bool CanJoinGroup(Message message)
|
private bool CanJoinGroup(Message message)
|
||||||
{
|
{
|
||||||
// If the group is empty, any message can join it
|
// If the group is empty, any message can join it
|
||||||
|
|||||||
@@ -119,6 +119,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteAttachmentAsync(
|
||||||
|
Attachment attachment,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", attachment.Id.ToString());
|
||||||
|
_writer.WriteString(
|
||||||
|
"url",
|
||||||
|
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
||||||
|
);
|
||||||
|
_writer.WriteString("fileName", attachment.FileName);
|
||||||
|
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask WriteEmbedAuthorAsync(
|
private async ValueTask WriteEmbedAuthorAsync(
|
||||||
EmbedAuthor embedAuthor,
|
EmbedAuthor embedAuthor,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -335,6 +353,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteStickerAsync(
|
||||||
|
Sticker sticker,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", sticker.Id.ToString());
|
||||||
|
_writer.WriteString("name", sticker.Name);
|
||||||
|
_writer.WriteString("format", sticker.Format.ToString());
|
||||||
|
_writer.WriteString(
|
||||||
|
"sourceUrl",
|
||||||
|
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync(
|
public override async ValueTask WritePreambleAsync(
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
@@ -437,19 +473,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
_writer.WriteStartArray("attachments");
|
_writer.WriteStartArray("attachments");
|
||||||
|
|
||||||
foreach (var attachment in message.Attachments)
|
foreach (var attachment in message.Attachments)
|
||||||
{
|
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
_writer.WriteString("id", attachment.Id.ToString());
|
|
||||||
_writer.WriteString(
|
|
||||||
"url",
|
|
||||||
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
|
||||||
);
|
|
||||||
_writer.WriteString("fileName", attachment.FileName);
|
|
||||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
@@ -465,19 +489,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
_writer.WriteStartArray("stickers");
|
_writer.WriteStartArray("stickers");
|
||||||
|
|
||||||
foreach (var sticker in message.Stickers)
|
foreach (var sticker in message.Stickers)
|
||||||
{
|
await WriteStickerAsync(sticker, cancellationToken);
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
_writer.WriteString("id", sticker.Id.ToString());
|
|
||||||
_writer.WriteString("name", sticker.Name);
|
|
||||||
_writer.WriteString("format", sticker.Format.ToString());
|
|
||||||
_writer.WriteString(
|
|
||||||
"sourceUrl",
|
|
||||||
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
|
||||||
);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
@@ -527,12 +539,58 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
if (message.Reference is not null)
|
if (message.Reference is not null)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("reference");
|
_writer.WriteStartObject("reference");
|
||||||
|
_writer.WriteString("type", message.Reference.Kind.ToString());
|
||||||
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
||||||
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
||||||
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forwarded message
|
||||||
|
if (message.ForwardedMessage is not null)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject("forwardedMessage");
|
||||||
|
|
||||||
|
_writer.WriteString(
|
||||||
|
"timestamp",
|
||||||
|
Context.NormalizeDate(message.ForwardedMessage.Timestamp)
|
||||||
|
);
|
||||||
|
|
||||||
|
_writer.WriteString(
|
||||||
|
"timestampEdited",
|
||||||
|
message.ForwardedMessage.EditedTimestamp?.Pipe(Context.NormalizeDate)
|
||||||
|
);
|
||||||
|
|
||||||
|
_writer.WriteString(
|
||||||
|
"content",
|
||||||
|
await FormatMarkdownAsync(message.ForwardedMessage.Content, cancellationToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Forwarded attachments
|
||||||
|
_writer.WriteStartArray("attachments");
|
||||||
|
|
||||||
|
foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||||
|
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Forwarded embeds
|
||||||
|
_writer.WriteStartArray("embeds");
|
||||||
|
foreach (var embed in message.ForwardedMessage.Embeds)
|
||||||
|
await WriteEmbedAsync(embed, cancellationToken);
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Forwarded stickers
|
||||||
|
_writer.WriteStartArray("stickers");
|
||||||
|
|
||||||
|
foreach (var sticker in message.ForwardedMessage.Stickers)
|
||||||
|
await WriteStickerAsync(sticker, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
// Interaction
|
// Interaction
|
||||||
if (message.Interaction is not null)
|
if (message.Interaction is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="chatlog__message-group">
|
<div class="chatlog__message-group">
|
||||||
@foreach (var (message, i) in Messages.WithIndex())
|
@foreach (var (i, message) in Messages.Index())
|
||||||
{
|
{
|
||||||
var isFirst = i == 0;
|
var isFirst = i == 0;
|
||||||
|
|
||||||
@@ -262,6 +262,95 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Forwarded message *@
|
||||||
|
@if (message is { IsForwarded: true, ForwardedMessage: not null })
|
||||||
|
{
|
||||||
|
<div class="chatlog__forwarded">
|
||||||
|
<div class="chatlog__forwarded-header">
|
||||||
|
<svg class="chatlog__forwarded-icon">
|
||||||
|
<use href="#forward-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<em>Forwarded</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Forwarded content *@
|
||||||
|
@if (!string.IsNullOrWhiteSpace(message.ForwardedMessage.Content))
|
||||||
|
{
|
||||||
|
<div class="chatlog__forwarded-content chatlog__markdown">
|
||||||
|
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.ForwardedMessage.Content))<!--/wmm:ignore--></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Forwarded attachments *@
|
||||||
|
@if (message.ForwardedMessage.Attachments.Any())
|
||||||
|
{
|
||||||
|
<div class="chatlog__forwarded-attachments">
|
||||||
|
@foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||||
|
{
|
||||||
|
@if (attachment.IsImage)
|
||||||
|
{
|
||||||
|
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||||
|
<img class="chatlog__forwarded-attachment" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else if (attachment.IsVideo)
|
||||||
|
{
|
||||||
|
<video class="chatlog__forwarded-attachment" controls>
|
||||||
|
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
else if (attachment.IsAudio)
|
||||||
|
{
|
||||||
|
<audio class="chatlog__forwarded-attachment" controls>
|
||||||
|
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
|
||||||
|
</audio>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__attachment-generic">
|
||||||
|
<svg class="chatlog__attachment-generic-icon">
|
||||||
|
<use href="#attachment-icon"/>
|
||||||
|
</svg>
|
||||||
|
<div class="chatlog__attachment-generic-name">
|
||||||
|
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||||
|
@attachment.FileName
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="chatlog__attachment-generic-size">
|
||||||
|
@attachment.FileSize
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Forwarded stickers *@
|
||||||
|
@foreach (var sticker in message.ForwardedMessage.Stickers)
|
||||||
|
{
|
||||||
|
<div class="chatlog__sticker" title="@sticker.Name">
|
||||||
|
@if (sticker.IsImage)
|
||||||
|
{
|
||||||
|
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||||
|
}
|
||||||
|
else if (sticker.Format == StickerFormat.Lottie)
|
||||||
|
{
|
||||||
|
<div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Forwarded timestamp *@
|
||||||
|
<div class="chatlog__forwarded-timestamp">
|
||||||
|
<span title="@FormatDate(message.ForwardedMessage.Timestamp, "f")">Originally sent: @FormatDate(message.ForwardedMessage.Timestamp)</span>
|
||||||
|
@if (message.ForwardedMessage.EditedTimestamp is not null)
|
||||||
|
{
|
||||||
|
<span title="@FormatDate(message.ForwardedMessage.EditedTimestamp.Value, "f")"> (edited)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Attachments *@
|
@* Attachments *@
|
||||||
@foreach (var attachment in message.Attachments)
|
@foreach (var attachment in message.Attachments)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -173,7 +174,7 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
|||||||
|
|
||||||
await _writer.WriteLineAsync("{Reactions}");
|
await _writer.WriteLineAsync("{Reactions}");
|
||||||
|
|
||||||
foreach (var (reaction, i) in reactions.WithIndex())
|
foreach (var (i, reaction) in reactions.Index())
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -224,6 +225,31 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
|||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteForwardedMessageAsync(
|
||||||
|
MessageSnapshot forwardedMessage,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await _writer.WriteLineAsync("{Forwarded Message}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(forwardedMessage.Content))
|
||||||
|
{
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await FormatMarkdownAsync(forwardedMessage.Content, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
$"Originally sent: {Context.FormatDate(forwardedMessage.Timestamp)}"
|
||||||
|
);
|
||||||
|
|
||||||
|
await WriteAttachmentsAsync(forwardedMessage.Attachments, cancellationToken);
|
||||||
|
await WriteEmbedsAsync(forwardedMessage.Embeds, cancellationToken);
|
||||||
|
await WriteStickersAsync(forwardedMessage.Stickers, cancellationToken);
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask WriteMessageAsync(
|
public override async ValueTask WriteMessageAsync(
|
||||||
Message message,
|
Message message,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -248,6 +274,12 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
|||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
|
|
||||||
|
// Forwarded message content
|
||||||
|
if (message.ForwardedMessage is not null)
|
||||||
|
{
|
||||||
|
await WriteForwardedMessageAsync(message.ForwardedMessage, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
// Attachments, embeds, reactions, etc.
|
// Attachments, embeds, reactions, etc.
|
||||||
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
||||||
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
||||||
|
|||||||
@@ -304,6 +304,52 @@
|
|||||||
unicode-bidi: bidi-override;
|
unicode-bidi: bidi-override;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-left: 4px solid @Themed("#4f545c", "#c7ccd1");
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: @Themed("#b5b6b8", "#5f5f60");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-content {
|
||||||
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-attachments {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-attachment {
|
||||||
|
max-width: 300px;
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-timestamp {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__system-notification-icon {
|
.chatlog__system-notification-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -999,6 +1045,9 @@
|
|||||||
<path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" />
|
<path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" />
|
||||||
<path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" />
|
<path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" />
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<symbol id="forward-icon" viewBox="0 0 24 24">
|
||||||
|
<path fill="#b9bbbe" d="M13 4L21 12L13 20V15C8 15 4 17 1 22C2 16 6 10 13 9V4Z" />
|
||||||
|
</symbol>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal static class MatcherExtensions
|
|||||||
this IMatcher<TContext, TValue> matcher,
|
this IMatcher<TContext, TValue> matcher,
|
||||||
TContext context,
|
TContext context,
|
||||||
StringSegment segment,
|
StringSegment segment,
|
||||||
Func<TContext, StringSegment, TValue> transformFallback
|
Func<TContext, StringSegment, TValue> fallbackTransform
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Loop through segments divided by individual matches
|
// Loop through segments divided by individual matches
|
||||||
@@ -40,7 +40,7 @@ internal static class MatcherExtensions
|
|||||||
|
|
||||||
yield return new ParsedMatch<TValue>(
|
yield return new ParsedMatch<TValue>(
|
||||||
fallbackSegment,
|
fallbackSegment,
|
||||||
transformFallback(context, fallbackSegment)
|
fallbackTransform(context, fallbackSegment)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ internal static class MatcherExtensions
|
|||||||
|
|
||||||
yield return new ParsedMatch<TValue>(
|
yield return new ParsedMatch<TValue>(
|
||||||
fallbackSegment,
|
fallbackSegment,
|
||||||
transformFallback(context, fallbackSegment)
|
fallbackTransform(context, fallbackSegment)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ internal static partial class MarkdownParser
|
|||||||
// Capture the shrug kaomoji.
|
// Capture the shrug kaomoji.
|
||||||
// This escapes it from matching for formatting.
|
// This escapes it from matching for formatting.
|
||||||
@"¯\_(ツ)_/¯",
|
@"¯\_(ツ)_/¯",
|
||||||
(s, _) => new TextNode(s.ToString())
|
(_, s) => new TextNode(s.ToString())
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ internal readonly record struct StringSegment(string Source, int StartIndex, int
|
|||||||
|
|
||||||
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
|
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
|
||||||
|
|
||||||
public override string ToString() => Source.Substring(StartIndex, Length);
|
public override string ToString() => Source[StartIndex..EndIndex];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,6 @@ 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)
|
extension<T>(IEnumerable<T?> source)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
@@ -34,4 +24,17 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,11 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Run -->
|
||||||
|
<Style Selector="Run">
|
||||||
|
<Setter Property="BaselineAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Text box -->
|
<!-- Text box -->
|
||||||
<Style Selector="TextBox">
|
<Style Selector="TextBox">
|
||||||
<Setter Property="FontSize" Value="14" />
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public class App : Application, IDisposable
|
|||||||
private readonly SettingsService _settingsService;
|
private readonly SettingsService _settingsService;
|
||||||
private readonly MainViewModel _mainViewModel;
|
private readonly MainViewModel _mainViewModel;
|
||||||
|
|
||||||
|
private bool _isDisposed;
|
||||||
|
|
||||||
public App()
|
public App()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
@@ -98,8 +100,24 @@ public class App : Application, IDisposable
|
|||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
desktop.MainWindow = new MainView { DataContext = _mainViewModel };
|
desktop.MainWindow = new MainView { DataContext = _mainViewModel };
|
||||||
|
|
||||||
|
void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs args)
|
||||||
|
{
|
||||||
|
if (sender is IControlledApplicationLifetime lifetime)
|
||||||
|
lifetime.Exit -= OnExit;
|
||||||
|
|
||||||
|
Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 += OnExit;
|
||||||
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
|
||||||
// Set up custom theme colors
|
// Set up custom theme colors
|
||||||
@@ -115,6 +133,11 @@ public class App : Application, IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
if (_isDisposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isDisposed = true;
|
||||||
|
|
||||||
_eventRoot.Dispose();
|
_eventRoot.Dispose();
|
||||||
_services.Dispose();
|
_services.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ public class MarkdownToInlinesConverter : IValueConverter
|
|||||||
{
|
{
|
||||||
case LiteralInline literal:
|
case LiteralInline literal:
|
||||||
{
|
{
|
||||||
var run = new Run(literal.Content.ToString())
|
var run = new Run(literal.Content.ToString());
|
||||||
{
|
|
||||||
BaselineAlignment = BaselineAlignment.Center,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fontWeight is not null)
|
if (fontWeight is not null)
|
||||||
run.FontWeight = fontWeight.Value;
|
run.FontWeight = fontWeight.Value;
|
||||||
@@ -142,9 +139,7 @@ public class MarkdownToInlinesConverter : IValueConverter
|
|||||||
|
|
||||||
var prefix = list.IsOrdered ? $"{itemOrder++}. " : $"{list.BulletType} ";
|
var prefix = list.IsOrdered ? $"{itemOrder++}. " : $"{list.BulletType} ";
|
||||||
|
|
||||||
inlines.Add(
|
inlines.Add(new Run(prefix));
|
||||||
new Run(prefix) { BaselineAlignment = BaselineAlignment.Center }
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (var subBlock in listItem.OfType<ParagraphBlock>())
|
foreach (var subBlock in listItem.OfType<ParagraphBlock>())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,15 @@
|
|||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<EncryptionSalt>HimalayanPinkSalt</EncryptionSalt>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Expose this property in code -->
|
||||||
|
<ProjectProperty Include="EncryptionSalt" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublishMacOSBundle>false</PublishMacOSBundle>
|
<PublishMacOSBundle>false</PublishMacOSBundle>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -23,25 +32,22 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
|
<PackageReference Include="AsyncImageLoader.Avalonia" />
|
||||||
<PackageReference Include="Avalonia" Version="11.3.11" />
|
<PackageReference Include="Avalonia" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.11" />
|
<PackageReference Include="Avalonia.Desktop" />
|
||||||
<PackageReference
|
<PackageReference Include="Avalonia.Diagnostics" Condition="'$(Configuration)' == 'Debug'" />
|
||||||
Include="Avalonia.Diagnostics"
|
<PackageReference Include="Cogwheel" />
|
||||||
Version="11.3.11"
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
Condition="'$(Configuration)' == 'Debug'"
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
/>
|
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||||
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
<PackageReference Include="DialogHost.Avalonia" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
<PackageReference Include="Markdig" />
|
||||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
<PackageReference Include="Material.Avalonia" />
|
||||||
<PackageReference Include="DialogHost.Avalonia" Version="0.10.4" />
|
<PackageReference Include="Material.Icons.Avalonia" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Markdig" Version="1.0.0" />
|
<PackageReference Include="Onova" />
|
||||||
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
|
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
|
||||||
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AsyncKeyedLock;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using DialogHostAvalonia;
|
using DialogHostAvalonia;
|
||||||
@@ -13,11 +13,12 @@ namespace DiscordChatExporter.Gui.Framework;
|
|||||||
|
|
||||||
public class DialogManager : IDisposable
|
public class DialogManager : IDisposable
|
||||||
{
|
{
|
||||||
private readonly AsyncNonKeyedLocker _dialogLock = new();
|
private readonly SemaphoreSlim _dialogLock = new(1, 1);
|
||||||
|
|
||||||
public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
|
public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
|
||||||
{
|
{
|
||||||
using (await _dialogLock.LockAsync())
|
await _dialogLock.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await DialogHost.Show(
|
await DialogHost.Show(
|
||||||
dialog,
|
dialog,
|
||||||
@@ -39,11 +40,15 @@ public class DialogManager : IDisposable
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Yield to allow DialogHost to fully reset its state before
|
// 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();
|
await Task.Yield();
|
||||||
|
|
||||||
return dialog.DialogResult;
|
return dialog.DialogResult;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dialogLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> PromptSaveFilePathAsync(
|
public async Task<string?> PromptSaveFilePathAsync(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public partial class LocalizationManager
|
|||||||
* Your bot needs to have the **Message Content Intent** enabled to read messages
|
* Your bot needs to have the **Message Content Intent** enabled to read messages
|
||||||
""",
|
""",
|
||||||
[nameof(TokenHelpText)] =
|
[nameof(TokenHelpText)] =
|
||||||
"If you have questions or issues, please refer to the [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
|
"If you have questions or issues, please refer to the [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
|
||||||
// Settings
|
// Settings
|
||||||
[nameof(SettingsTitle)] = "Settings",
|
[nameof(SettingsTitle)] = "Settings",
|
||||||
[nameof(ThemeLabel)] = "Theme",
|
[nameof(ThemeLabel)] = "Theme",
|
||||||
@@ -52,8 +52,10 @@ public partial class LocalizationManager
|
|||||||
[nameof(AutoUpdateLabel)] = "Auto-update",
|
[nameof(AutoUpdateLabel)] = "Auto-update",
|
||||||
[nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch",
|
[nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch",
|
||||||
[nameof(PersistTokenLabel)] = "Persist token",
|
[nameof(PersistTokenLabel)] = "Persist token",
|
||||||
[nameof(PersistTokenTooltip)] =
|
[nameof(PersistTokenTooltip)] = """
|
||||||
"Save the last used token to a file so that it can be persisted between sessions",
|
Save the last used token to a file so that it can be persisted between sessions.
|
||||||
|
**Warning**: although the token is stored with encryption, it may still be recovered by an attacker who has access to your system.
|
||||||
|
""",
|
||||||
[nameof(RateLimitPreferenceLabel)] = "Rate limit preference",
|
[nameof(RateLimitPreferenceLabel)] = "Rate limit preference",
|
||||||
[nameof(RateLimitPreferenceTooltip)] =
|
[nameof(RateLimitPreferenceTooltip)] =
|
||||||
"Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.",
|
"Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.",
|
||||||
@@ -76,17 +78,17 @@ public partial class LocalizationManager
|
|||||||
Directory paths must end with a slash to avoid ambiguity.
|
Directory paths must end with a slash to avoid ambiguity.
|
||||||
|
|
||||||
Available template tokens:
|
Available template tokens:
|
||||||
- **%g** — server ID
|
**%g** — server ID
|
||||||
- **%G** — server name
|
**%G** — server name
|
||||||
- **%t** — category ID
|
**%t** — category ID
|
||||||
- **%T** — category name
|
**%T** — category name
|
||||||
- **%c** — channel ID
|
**%c** — channel ID
|
||||||
- **%C** — channel name
|
**%C** — channel name
|
||||||
- **%p** — channel position
|
**%p** — channel position
|
||||||
- **%P** — category position
|
**%P** — category position
|
||||||
- **%a** — after date
|
**%a** — after date
|
||||||
- **%b** — before date
|
**%b** — before date
|
||||||
- **%d** — current date
|
**%d** — current date
|
||||||
""",
|
""",
|
||||||
[nameof(FormatLabel)] = "Format",
|
[nameof(FormatLabel)] = "Format",
|
||||||
[nameof(FormatTooltip)] = "Export format",
|
[nameof(FormatTooltip)] = "Export format",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public partial class LocalizationManager
|
|||||||
* Votre bot doit avoir l'option **Message Content Intent** activée pour lire les messages
|
* Votre bot doit avoir l'option **Message Content Intent** activée pour lire les messages
|
||||||
""",
|
""",
|
||||||
[nameof(TokenHelpText)] =
|
[nameof(TokenHelpText)] =
|
||||||
"Pour les questions ou problèmes, veuillez consulter la [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
|
"Pour les questions ou problèmes, veuillez consulter la [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
|
||||||
// Settings
|
// Settings
|
||||||
[nameof(SettingsTitle)] = "Paramètres",
|
[nameof(SettingsTitle)] = "Paramètres",
|
||||||
[nameof(ThemeLabel)] = "Thème",
|
[nameof(ThemeLabel)] = "Thème",
|
||||||
@@ -54,8 +54,10 @@ public partial class LocalizationManager
|
|||||||
[nameof(AutoUpdateLabel)] = "Mise à jour automatique",
|
[nameof(AutoUpdateLabel)] = "Mise à jour automatique",
|
||||||
[nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement",
|
[nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement",
|
||||||
[nameof(PersistTokenLabel)] = "Conserver le token",
|
[nameof(PersistTokenLabel)] = "Conserver le token",
|
||||||
[nameof(PersistTokenTooltip)] =
|
[nameof(PersistTokenTooltip)] = """
|
||||||
"Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions",
|
Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions.
|
||||||
|
**Avertissement** : bien que le token soit stocké avec chiffrement, il peut toujours être récupéré par un attaquant ayant accès à votre système.
|
||||||
|
""",
|
||||||
[nameof(RateLimitPreferenceLabel)] = "Préférence de limite de débit",
|
[nameof(RateLimitPreferenceLabel)] = "Préférence de limite de débit",
|
||||||
[nameof(RateLimitPreferenceTooltip)] =
|
[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.",
|
"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.",
|
||||||
@@ -78,17 +80,17 @@ public partial class LocalizationManager
|
|||||||
Les chemins de répertoire doivent se terminer par un slash pour éviter toute ambiguïté.
|
Les chemins de répertoire doivent se terminer par un slash pour éviter toute ambiguïté.
|
||||||
|
|
||||||
Jetons de modèle disponibles :
|
Jetons de modèle disponibles :
|
||||||
- **%g** — ID du serveur
|
**%g** — ID du serveur
|
||||||
- **%G** — nom du serveur
|
**%G** — nom du serveur
|
||||||
- **%t** — ID de la catégorie
|
**%t** — ID de la catégorie
|
||||||
- **%T** — nom de la catégorie
|
**%T** — nom de la catégorie
|
||||||
- **%c** — ID du canal
|
**%c** — ID du canal
|
||||||
- **%C** — nom du canal
|
**%C** — nom du canal
|
||||||
- **%p** — position du canal
|
**%p** — position du canal
|
||||||
- **%P** — position de la catégorie
|
**%P** — position de la catégorie
|
||||||
- **%a** — date après
|
**%a** — date après
|
||||||
- **%b** — date avant
|
**%b** — date avant
|
||||||
- **%d** — date actuelle
|
**%d** — date actuelle
|
||||||
""",
|
""",
|
||||||
[nameof(FormatLabel)] = "Format",
|
[nameof(FormatLabel)] = "Format",
|
||||||
[nameof(FormatTooltip)] = "Format d'exportation",
|
[nameof(FormatTooltip)] = "Format d'exportation",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public partial class LocalizationManager
|
|||||||
* Ihr Bot benötigt die aktivierte **Message Content Intent**, um Nachrichten zu lesen
|
* Ihr Bot benötigt die aktivierte **Message Content Intent**, um Nachrichten zu lesen
|
||||||
""",
|
""",
|
||||||
[nameof(TokenHelpText)] =
|
[nameof(TokenHelpText)] =
|
||||||
"Bei Fragen oder Problemen lesen Sie die [Dokumentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
|
"Bei Fragen oder Problemen lesen Sie die [Dokumentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
|
||||||
// Settings
|
// Settings
|
||||||
[nameof(SettingsTitle)] = "Einstellungen",
|
[nameof(SettingsTitle)] = "Einstellungen",
|
||||||
[nameof(ThemeLabel)] = "Design",
|
[nameof(ThemeLabel)] = "Design",
|
||||||
@@ -54,8 +54,10 @@ public partial class LocalizationManager
|
|||||||
[nameof(AutoUpdateLabel)] = "Automatische Updates",
|
[nameof(AutoUpdateLabel)] = "Automatische Updates",
|
||||||
[nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen",
|
[nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen",
|
||||||
[nameof(PersistTokenLabel)] = "Token speichern",
|
[nameof(PersistTokenLabel)] = "Token speichern",
|
||||||
[nameof(PersistTokenTooltip)] =
|
[nameof(PersistTokenTooltip)] = """
|
||||||
"Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt",
|
Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt.
|
||||||
|
**Warnung**: Der Token wird mit Verschlüsselung gespeichert, kann aber dennoch von einem Angreifer mit Zugriff auf Ihr System wiederhergestellt werden.
|
||||||
|
""",
|
||||||
[nameof(RateLimitPreferenceLabel)] = "Ratenlimit-Einstellung",
|
[nameof(RateLimitPreferenceLabel)] = "Ratenlimit-Einstellung",
|
||||||
[nameof(RateLimitPreferenceTooltip)] =
|
[nameof(RateLimitPreferenceTooltip)] =
|
||||||
"Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.",
|
"Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.",
|
||||||
@@ -78,17 +80,17 @@ public partial class LocalizationManager
|
|||||||
Verzeichnispfade müssen mit einem Schrägstrich enden, um Mehrdeutigkeiten zu vermeiden.
|
Verzeichnispfade müssen mit einem Schrägstrich enden, um Mehrdeutigkeiten zu vermeiden.
|
||||||
|
|
||||||
Verfügbare Vorlagen-Token:
|
Verfügbare Vorlagen-Token:
|
||||||
- **%g** — Server-ID
|
**%g** — Server-ID
|
||||||
- **%G** — Servername
|
**%G** — Servername
|
||||||
- **%t** — Kategorie-ID
|
**%t** — Kategorie-ID
|
||||||
- **%T** — Kategoriename
|
**%T** — Kategoriename
|
||||||
- **%c** — Kanal-ID
|
**%c** — Kanal-ID
|
||||||
- **%C** — Kanalname
|
**%C** — Kanalname
|
||||||
- **%p** — Kanalposition
|
**%p** — Kanalposition
|
||||||
- **%P** — Kategorieposition
|
**%P** — Kategorieposition
|
||||||
- **%a** — Datum ab
|
**%a** — Datum ab
|
||||||
- **%b** — Datum bis
|
**%b** — Datum bis
|
||||||
- **%d** — aktuelles Datum
|
**%d** — aktuelles Datum
|
||||||
""",
|
""",
|
||||||
[nameof(FormatLabel)] = "Format",
|
[nameof(FormatLabel)] = "Format",
|
||||||
[nameof(FormatTooltip)] = "Exportformat",
|
[nameof(FormatTooltip)] = "Exportformat",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public partial class LocalizationManager
|
|||||||
* Tu bot necesita tener habilitado **Message Content Intent** para leer mensajes
|
* Tu bot necesita tener habilitado **Message Content Intent** para leer mensajes
|
||||||
""",
|
""",
|
||||||
[nameof(TokenHelpText)] =
|
[nameof(TokenHelpText)] =
|
||||||
"Si tienes preguntas o problemas, consulta la [documentación](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
|
"Si tienes preguntas o problemas, consulta la [documentación](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
|
||||||
// Settings
|
// Settings
|
||||||
[nameof(SettingsTitle)] = "Ajustes",
|
[nameof(SettingsTitle)] = "Ajustes",
|
||||||
[nameof(ThemeLabel)] = "Tema",
|
[nameof(ThemeLabel)] = "Tema",
|
||||||
@@ -52,8 +52,10 @@ public partial class LocalizationManager
|
|||||||
[nameof(AutoUpdateLabel)] = "Actualización automática",
|
[nameof(AutoUpdateLabel)] = "Actualización automática",
|
||||||
[nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio",
|
[nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio",
|
||||||
[nameof(PersistTokenLabel)] = "Guardar token",
|
[nameof(PersistTokenLabel)] = "Guardar token",
|
||||||
[nameof(PersistTokenTooltip)] =
|
[nameof(PersistTokenTooltip)] = """
|
||||||
"Guardar el último token utilizado en un archivo para conservarlo entre sesiones",
|
Guardar el último token utilizado en un archivo para conservarlo entre sesiones.
|
||||||
|
**Advertencia**: aunque el token se almacena con cifrado, aún puede ser recuperado por un atacante con acceso a tu sistema.
|
||||||
|
""",
|
||||||
[nameof(RateLimitPreferenceLabel)] = "Preferencia de límite de velocidad",
|
[nameof(RateLimitPreferenceLabel)] = "Preferencia de límite de velocidad",
|
||||||
[nameof(RateLimitPreferenceTooltip)] =
|
[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).",
|
"Si se deben respetar los límites de velocidad recomendados. Si está desactivado, solo se respetarán los límites estrictos (respuestas 429).",
|
||||||
@@ -76,17 +78,17 @@ public partial class LocalizationManager
|
|||||||
Las rutas de directorio deben terminar con una barra diagonal para evitar ambigüedades.
|
Las rutas de directorio deben terminar con una barra diagonal para evitar ambigüedades.
|
||||||
|
|
||||||
Tokens de plantilla disponibles:
|
Tokens de plantilla disponibles:
|
||||||
- **%g** — ID del servidor
|
**%g** — ID del servidor
|
||||||
- **%G** — nombre del servidor
|
**%G** — nombre del servidor
|
||||||
- **%t** — ID de categoría
|
**%t** — ID de categoría
|
||||||
- **%T** — nombre de categoría
|
**%T** — nombre de categoría
|
||||||
- **%c** — ID del canal
|
**%c** — ID del canal
|
||||||
- **%C** — nombre del canal
|
**%C** — nombre del canal
|
||||||
- **%p** — posición del canal
|
**%p** — posición del canal
|
||||||
- **%P** — posición de la categoría
|
**%P** — posición de la categoría
|
||||||
- **%a** — fecha desde
|
**%a** — fecha desde
|
||||||
- **%b** — fecha hasta
|
**%b** — fecha hasta
|
||||||
- **%d** — fecha actual
|
**%d** — fecha actual
|
||||||
""",
|
""",
|
||||||
[nameof(FormatLabel)] = "Formato",
|
[nameof(FormatLabel)] = "Formato",
|
||||||
[nameof(FormatTooltip)] = "Formato de exportación",
|
[nameof(FormatTooltip)] = "Formato de exportación",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public partial class LocalizationManager
|
|||||||
* Ваш бот повинен мати включений **Message Content Intent** для читання повідомлень
|
* Ваш бот повинен мати включений **Message Content Intent** для читання повідомлень
|
||||||
""",
|
""",
|
||||||
[nameof(TokenHelpText)] =
|
[nameof(TokenHelpText)] =
|
||||||
"Якщо у вас є запитання або проблеми, зверніться до [документації](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
|
"Якщо у вас є запитання або проблеми, зверніться до [документації](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
|
||||||
// Settings
|
// Settings
|
||||||
[nameof(SettingsTitle)] = "Налаштування",
|
[nameof(SettingsTitle)] = "Налаштування",
|
||||||
[nameof(ThemeLabel)] = "Тема",
|
[nameof(ThemeLabel)] = "Тема",
|
||||||
@@ -52,8 +52,10 @@ public partial class LocalizationManager
|
|||||||
[nameof(AutoUpdateLabel)] = "Авто-оновлення",
|
[nameof(AutoUpdateLabel)] = "Авто-оновлення",
|
||||||
[nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску",
|
[nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску",
|
||||||
[nameof(PersistTokenLabel)] = "Зберігати токен",
|
[nameof(PersistTokenLabel)] = "Зберігати токен",
|
||||||
[nameof(PersistTokenTooltip)] =
|
[nameof(PersistTokenTooltip)] = """
|
||||||
"Зберігати останній використаний токен у файлі для збереження між сеансами",
|
Зберігати останній використаний токен у файлі для збереження між сеансами.
|
||||||
|
**Увага**: хоча токен зберігається із шифруванням, він може бути відновлений зловмисником, який має доступ до вашої системи.
|
||||||
|
""",
|
||||||
[nameof(RateLimitPreferenceLabel)] = "Ліміт запитів",
|
[nameof(RateLimitPreferenceLabel)] = "Ліміт запитів",
|
||||||
[nameof(RateLimitPreferenceTooltip)] =
|
[nameof(RateLimitPreferenceTooltip)] =
|
||||||
"Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).",
|
"Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).",
|
||||||
@@ -76,17 +78,17 @@ public partial class LocalizationManager
|
|||||||
Шляхи до директорій повинні закінчуватись слешем для уникнення неоднозначності.
|
Шляхи до директорій повинні закінчуватись слешем для уникнення неоднозначності.
|
||||||
|
|
||||||
Доступні шаблонні токени:
|
Доступні шаблонні токени:
|
||||||
- **%g** — ID сервера
|
**%g** — ID сервера
|
||||||
- **%G** — назва сервера
|
**%G** — назва сервера
|
||||||
- **%t** — ID категорії
|
**%t** — ID категорії
|
||||||
- **%T** — назва категорії
|
**%T** — назва категорії
|
||||||
- **%c** — ID каналу
|
**%c** — ID каналу
|
||||||
- **%C** — назва каналу
|
**%C** — назва каналу
|
||||||
- **%p** — позиція каналу
|
**%p** — позиція каналу
|
||||||
- **%P** — позиція категорії
|
**%P** — позиція категорії
|
||||||
- **%a** — дата після
|
**%a** — дата після
|
||||||
- **%b** — дата до
|
**%b** — дата до
|
||||||
- **%d** — поточна дата
|
**%d** — поточна дата
|
||||||
""",
|
""",
|
||||||
[nameof(FormatLabel)] = "Формат",
|
[nameof(FormatLabel)] = "Формат",
|
||||||
[nameof(FormatTooltip)] = "Формат експорту",
|
[nameof(FormatTooltip)] = "Формат експорту",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public static class Program
|
|||||||
|
|
||||||
public static string ProjectReleasesUrl { get; } = $"{ProjectUrl}/releases";
|
public static string ProjectReleasesUrl { get; } = $"{ProjectUrl}/releases";
|
||||||
|
|
||||||
public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
|
public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/prime/.docs";
|
||||||
|
|
||||||
public static AppBuilder BuildAvaloniaApp() =>
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace();
|
AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace();
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Services;
|
||||||
|
|
||||||
|
public partial class SettingsService
|
||||||
|
{
|
||||||
|
private class TokenEncryptionConverter : JsonConverter<string?>
|
||||||
|
{
|
||||||
|
private const string Prefix = "enc:";
|
||||||
|
|
||||||
|
private static readonly Lazy<byte[]> Key = new(() =>
|
||||||
|
Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
Encoding.UTF8.GetBytes(Environment.TryGetMachineId() ?? string.Empty),
|
||||||
|
Encoding.UTF8.GetBytes(ThisAssembly.Project.EncryptionSalt),
|
||||||
|
600_000,
|
||||||
|
HashAlgorithmName.SHA256,
|
||||||
|
16
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
public override string? Read(
|
||||||
|
ref Utf8JsonReader reader,
|
||||||
|
Type typeToConvert,
|
||||||
|
JsonSerializerOptions options
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var value = reader.GetString();
|
||||||
|
|
||||||
|
// No prefix means the token is stored as plain text, which was the case for older
|
||||||
|
// versions of the application. Load it as is and encrypt it on next save.
|
||||||
|
if (
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
|| !value.StartsWith(Prefix, StringComparison.Ordinal)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encryptedData = Convert.FromHexString(value[Prefix.Length..]);
|
||||||
|
var tokenData = new byte[encryptedData.AsSpan(28).Length];
|
||||||
|
|
||||||
|
// Layout: nonce (12 bytes) | tag (16 bytes) | cipher
|
||||||
|
using var aes = new AesGcm(Key.Value, 16);
|
||||||
|
aes.Decrypt(
|
||||||
|
encryptedData.AsSpan(0, 12),
|
||||||
|
encryptedData.AsSpan(28),
|
||||||
|
encryptedData.AsSpan(12, 16),
|
||||||
|
tokenData
|
||||||
|
);
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(tokenData);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
when (ex
|
||||||
|
is FormatException
|
||||||
|
or CryptographicException
|
||||||
|
or ArgumentException
|
||||||
|
or IndexOutOfRangeException
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(
|
||||||
|
Utf8JsonWriter writer,
|
||||||
|
string? value,
|
||||||
|
JsonSerializerOptions options
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
writer.WriteNullValue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenData = Encoding.UTF8.GetBytes(value);
|
||||||
|
var encryptedData = new byte[28 + tokenData.Length];
|
||||||
|
|
||||||
|
// Nonce
|
||||||
|
RandomNumberGenerator.Fill(encryptedData.AsSpan(0, 12));
|
||||||
|
|
||||||
|
// Layout: nonce (12 bytes) | tag (16 bytes) | cipher
|
||||||
|
using var aes = new AesGcm(Key.Value, 16);
|
||||||
|
aes.Encrypt(
|
||||||
|
encryptedData.AsSpan(0, 12),
|
||||||
|
tokenData,
|
||||||
|
encryptedData.AsSpan(28),
|
||||||
|
encryptedData.AsSpan(12, 16)
|
||||||
|
);
|
||||||
|
|
||||||
|
writer.WriteStringValue(Prefix + Convert.ToHexStringLower(encryptedData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using System.Text.Json.Serialization;
|
||||||
using System.IO;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using Cogwheel;
|
using Cogwheel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
@@ -13,10 +11,7 @@ namespace DiscordChatExporter.Gui.Services;
|
|||||||
|
|
||||||
[ObservableObject]
|
[ObservableObject]
|
||||||
public partial class SettingsService()
|
public partial class SettingsService()
|
||||||
: SettingsBase(
|
: SettingsBase(StartOptions.Current.SettingsPath, SerializerContext.Default)
|
||||||
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
|
|
||||||
SerializerContext.Default
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsUkraineSupportMessageEnabled { get; set; } = true;
|
public partial bool IsUkraineSupportMessageEnabled { get; set; } = true;
|
||||||
@@ -50,6 +45,7 @@ public partial class SettingsService()
|
|||||||
public partial int ParallelLimit { get; set; } = 1;
|
public partial int ParallelLimit { get; set; } = 1;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
[JsonConverter(typeof(TokenEncryptionConverter))]
|
||||||
public partial string? LastToken { get; set; }
|
public partial string? LastToken { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
|||||||
@@ -9,24 +9,25 @@ namespace DiscordChatExporter.Gui.Services;
|
|||||||
|
|
||||||
public class UpdateService(SettingsService settingsService) : IDisposable
|
public class UpdateService(SettingsService settingsService) : IDisposable
|
||||||
{
|
{
|
||||||
private readonly IUpdateManager? _updateManager = OperatingSystem.IsWindows()
|
private readonly IUpdateManager? _updateManager =
|
||||||
? new UpdateManager(
|
OperatingSystem.IsWindows() && StartOptions.Current.IsAutoUpdateAllowed
|
||||||
new GithubPackageResolver(
|
? new UpdateManager(
|
||||||
"Tyrrrz",
|
new GithubPackageResolver(
|
||||||
"DiscordChatExporter",
|
"Tyrrrz",
|
||||||
// Examples:
|
"DiscordChatExporter",
|
||||||
// DiscordChatExporter.win-arm64.zip
|
// Examples:
|
||||||
// DiscordChatExporter.win-x64.zip
|
// DiscordChatExporter.win-arm64.zip
|
||||||
// DiscordChatExporter.linux-x64.zip
|
// DiscordChatExporter.win-x64.zip
|
||||||
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
|
// DiscordChatExporter.linux-x64.zip
|
||||||
),
|
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
|
||||||
new ZipPackageExtractor()
|
),
|
||||||
)
|
new ZipPackageExtractor()
|
||||||
: null;
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
private Version? _updateVersion;
|
private Version? _updateVersion;
|
||||||
private bool _updatePrepared;
|
private bool _isUpdatePrepared;
|
||||||
private bool _updaterLaunched;
|
private bool _isUpdaterLaunched;
|
||||||
|
|
||||||
public async ValueTask<Version?> CheckForUpdatesAsync()
|
public async ValueTask<Version?> CheckForUpdatesAsync()
|
||||||
{
|
{
|
||||||
@@ -51,7 +52,7 @@ public class UpdateService(SettingsService settingsService) : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _updateManager.PrepareUpdateAsync(_updateVersion = version);
|
await _updateManager.PrepareUpdateAsync(_updateVersion = version);
|
||||||
_updatePrepared = true;
|
_isUpdatePrepared = true;
|
||||||
}
|
}
|
||||||
catch (UpdaterAlreadyLaunchedException)
|
catch (UpdaterAlreadyLaunchedException)
|
||||||
{
|
{
|
||||||
@@ -71,13 +72,13 @@ public class UpdateService(SettingsService settingsService) : IDisposable
|
|||||||
if (!settingsService.IsAutoUpdateEnabled)
|
if (!settingsService.IsAutoUpdateEnabled)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (_updateVersion is null || !_updatePrepared || _updaterLaunched)
|
if (_updateVersion is null || !_isUpdatePrepared || _isUpdaterLaunched)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_updateManager.LaunchUpdater(_updateVersion, needRestart);
|
_updateManager.LaunchUpdater(_updateVersion, needRestart);
|
||||||
_updaterLaunched = true;
|
_isUpdaterLaunched = true;
|
||||||
}
|
}
|
||||||
catch (UpdaterAlreadyLaunchedException)
|
catch (UpdaterAlreadyLaunchedException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
|
|
||||||
|
internal static class EnvironmentExtensions
|
||||||
|
{
|
||||||
|
extension(Environment)
|
||||||
|
{
|
||||||
|
public static string? TryGetMachineId()
|
||||||
|
{
|
||||||
|
// Windows: stable GUID written during OS installation
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var regKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(
|
||||||
|
@"SOFTWARE\Microsoft\Cryptography"
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
regKey?.GetValue("MachineGuid") is string guid
|
||||||
|
&& !string.IsNullOrWhiteSpace(guid)
|
||||||
|
)
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Unix: /etc/machine-id (set once by systemd at first boot)
|
||||||
|
foreach (var path in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = File.ReadAllText(path).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(id))
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-resort fallback
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Environment.MachineName;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ internal static class ProcessExtensions
|
|||||||
public static void StartShellExecute(string path)
|
public static void StartShellExecute(string path)
|
||||||
{
|
{
|
||||||
using var process = new Process();
|
using var process = new Process();
|
||||||
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
|
process.StartInfo = new ProcessStartInfo(path) { UseShellExecute = true };
|
||||||
|
|
||||||
process.Start();
|
process.Start();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ public class SettingsViewModel : DialogViewModelBase
|
|||||||
set => _settingsService.Language = value;
|
set => _settingsService.Language = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsAutoUpdateAvailable { get; } =
|
||||||
|
OperatingSystem.IsWindows() && StartOptions.Current.IsAutoUpdateAllowed;
|
||||||
|
|
||||||
public bool IsAutoUpdateEnabled
|
public bool IsAutoUpdateEnabled
|
||||||
{
|
{
|
||||||
get => _settingsService.IsAutoUpdateEnabled;
|
get => _settingsService.IsAutoUpdateEnabled;
|
||||||
|
|||||||
@@ -1,54 +1,54 @@
|
|||||||
<UserControl
|
<UserControl
|
||||||
Loaded="UserControl_OnLoaded"
|
|
||||||
x:Class="DiscordChatExporter.Gui.Views.Components.DashboardView"
|
x:Class="DiscordChatExporter.Gui.Views.Components.DashboardView"
|
||||||
x:DataType="components:DashboardViewModel"
|
|
||||||
x:Name="UserControl"
|
|
||||||
xmlns="https://github.com/avaloniaui"
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
|
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
|
||||||
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
|
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
|
||||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||||
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
x:Name="UserControl"
|
||||||
|
x:DataType="components:DashboardViewModel"
|
||||||
|
Loaded="UserControl_OnLoaded">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<StackPanel
|
<StackPanel
|
||||||
Background="{DynamicResource MaterialDarkBackgroundBrush}"
|
Background="{DynamicResource MaterialDarkBackgroundBrush}"
|
||||||
DockPanel.Dock="Top"
|
DockPanel.Dock="Top"
|
||||||
Orientation="Vertical">
|
Orientation="Vertical">
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="12,12,8,12">
|
<Grid Margin="12,12,8,12" ColumnDefinitions="*,Auto">
|
||||||
<materialStyles:Card Grid.Column="0">
|
<materialStyles:Card Grid.Column="0">
|
||||||
<!-- Token -->
|
<!-- Token -->
|
||||||
<TextBox
|
<TextBox
|
||||||
|
x:Name="TokenValueTextBox"
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
PasswordChar="*"
|
PasswordChar="*"
|
||||||
RevealPassword="{Binding $self.IsFocused}"
|
RevealPassword="{Binding $self.IsFocused}"
|
||||||
Text="{Binding Token}"
|
Text="{Binding Token}"
|
||||||
Theme="{DynamicResource SoloTextBox}"
|
Theme="{DynamicResource SoloTextBox}"
|
||||||
Watermark="{Binding LocalizationManager.TokenWatermark}"
|
Watermark="{Binding LocalizationManager.TokenWatermark}">
|
||||||
x:Name="TokenValueTextBox">
|
|
||||||
<TextBox.InnerLeftContent>
|
<TextBox.InnerLeftContent>
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
|
Width="24"
|
||||||
Height="24"
|
Height="24"
|
||||||
Kind="Key"
|
|
||||||
Margin="4,0,8,0"
|
Margin="4,0,8,0"
|
||||||
Width="24" />
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
Kind="Key" />
|
||||||
</TextBox.InnerLeftContent>
|
</TextBox.InnerLeftContent>
|
||||||
<TextBox.InnerRightContent>
|
<TextBox.InnerRightContent>
|
||||||
<Button
|
<Button
|
||||||
Command="{Binding PullGuildsCommand}"
|
|
||||||
Grid.Column="2"
|
Grid.Column="2"
|
||||||
IsDefault="True"
|
|
||||||
Margin="8,0,0,0"
|
Margin="8,0,0,0"
|
||||||
Padding="4"
|
Padding="4"
|
||||||
|
Command="{Binding PullGuildsCommand}"
|
||||||
|
IsDefault="True"
|
||||||
Theme="{DynamicResource MaterialFlatButton}"
|
Theme="{DynamicResource MaterialFlatButton}"
|
||||||
ToolTip.Tip="{Binding LocalizationManager.PullGuildsTooltip}">
|
ToolTip.Tip="{Binding LocalizationManager.PullGuildsTooltip}">
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
|
Width="24"
|
||||||
Height="24"
|
Height="24"
|
||||||
Kind="ArrowRight"
|
Kind="ArrowRight" />
|
||||||
Width="24" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</TextBox.InnerRightContent>
|
</TextBox.InnerRightContent>
|
||||||
</TextBox>
|
</TextBox>
|
||||||
@@ -56,25 +56,25 @@
|
|||||||
|
|
||||||
<!-- Settings button -->
|
<!-- Settings button -->
|
||||||
<Button
|
<Button
|
||||||
Command="{Binding ShowSettingsCommand}"
|
|
||||||
Foreground="{DynamicResource MaterialDarkForegroundBrush}"
|
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="8,0,0,0"
|
Margin="8,0,0,0"
|
||||||
Padding="8"
|
Padding="8"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Command="{Binding ShowSettingsCommand}"
|
||||||
|
Foreground="{DynamicResource MaterialDarkForegroundBrush}"
|
||||||
Theme="{DynamicResource MaterialFlatButton}"
|
Theme="{DynamicResource MaterialFlatButton}"
|
||||||
ToolTip.Tip="{Binding LocalizationManager.SettingsTooltip}"
|
ToolTip.Tip="{Binding LocalizationManager.SettingsTooltip}">
|
||||||
VerticalAlignment="Center">
|
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
|
Width="24"
|
||||||
Height="24"
|
Height="24"
|
||||||
Kind="Settings"
|
Kind="Settings" />
|
||||||
Width="24" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Progress -->
|
<!-- Progress -->
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
Background="Transparent"
|
|
||||||
Height="2"
|
Height="2"
|
||||||
|
Background="Transparent"
|
||||||
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||||
Value="{Binding Progress.Current.Fraction, Mode=OneWay}" />
|
Value="{Binding Progress.Current.Fraction, Mode=OneWay}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -95,16 +95,16 @@
|
|||||||
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding !!AvailableGuilds.Count}">
|
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding !!AvailableGuilds.Count}">
|
||||||
<!-- Guilds -->
|
<!-- Guilds -->
|
||||||
<Border
|
<Border
|
||||||
|
Grid.Column="0"
|
||||||
BorderBrush="{DynamicResource MaterialDividerBrush}"
|
BorderBrush="{DynamicResource MaterialDividerBrush}"
|
||||||
BorderThickness="0,0,1,0"
|
BorderThickness="0,0,1,0">
|
||||||
Grid.Column="0">
|
|
||||||
<ListBox
|
<ListBox
|
||||||
|
x:Name="AvailableGuildsListBox"
|
||||||
ItemsSource="{Binding AvailableGuilds}"
|
ItemsSource="{Binding AvailableGuilds}"
|
||||||
ScrollViewer.VerticalScrollBarVisibility="Hidden"
|
ScrollViewer.VerticalScrollBarVisibility="Hidden"
|
||||||
SelectedItem="{Binding SelectedGuild}"
|
SelectedItem="{Binding SelectedGuild}"
|
||||||
SelectionChanged="AvailableGuildsListBox_OnSelectionChanged"
|
SelectionChanged="AvailableGuildsListBox_OnSelectionChanged"
|
||||||
SelectionMode="Single"
|
SelectionMode="Single">
|
||||||
x:Name="AvailableGuildsListBox">
|
|
||||||
<ListBox.Styles>
|
<ListBox.Styles>
|
||||||
<Style Selector="ListBox">
|
<Style Selector="ListBox">
|
||||||
<Style Selector="^ ListBoxItem">
|
<Style Selector="^ ListBoxItem">
|
||||||
@@ -118,16 +118,16 @@
|
|||||||
<Panel Background="Transparent" ToolTip.Tip="{Binding Name}">
|
<Panel Background="Transparent" ToolTip.Tip="{Binding Name}">
|
||||||
<!-- Guild icon placeholder -->
|
<!-- Guild icon placeholder -->
|
||||||
<Ellipse
|
<Ellipse
|
||||||
Fill="{DynamicResource MaterialDividerBrush}"
|
Width="48"
|
||||||
Height="48"
|
Height="48"
|
||||||
Margin="12"
|
Margin="12"
|
||||||
Width="48" />
|
Fill="{DynamicResource MaterialDividerBrush}" />
|
||||||
|
|
||||||
<!-- Guild icon -->
|
<!-- Guild icon -->
|
||||||
<Ellipse
|
<Ellipse
|
||||||
|
Width="48"
|
||||||
Height="48"
|
Height="48"
|
||||||
Margin="12"
|
Margin="12">
|
||||||
Width="48">
|
|
||||||
<Ellipse.Fill>
|
<Ellipse.Fill>
|
||||||
<ImageBrush asyncImageLoader:ImageBrushLoader.Source="{Binding IconUrl}" />
|
<ImageBrush asyncImageLoader:ImageBrushLoader.Source="{Binding IconUrl}" />
|
||||||
</Ellipse.Fill>
|
</Ellipse.Fill>
|
||||||
@@ -141,12 +141,12 @@
|
|||||||
<!-- Channels -->
|
<!-- Channels -->
|
||||||
<Border Grid.Column="1">
|
<Border Grid.Column="1">
|
||||||
<TreeView
|
<TreeView
|
||||||
|
x:Name="AvailableChannelsTreeView"
|
||||||
ItemsSource="{Binding AvailableChannels}"
|
ItemsSource="{Binding AvailableChannels}"
|
||||||
SelectedItems="{Binding SelectedChannels}"
|
SelectedItems="{Binding SelectedChannels}"
|
||||||
SelectionChanged="AvailableChannelsTreeView_OnSelectionChanged"
|
SelectionChanged="AvailableChannelsTreeView_OnSelectionChanged"
|
||||||
SelectionMode="Multiple"
|
SelectionMode="Multiple"
|
||||||
TextSearch.Text="Name"
|
TextSearch.Text="Name">
|
||||||
x:Name="AvailableChannelsTreeView">
|
|
||||||
<TreeView.Styles>
|
<TreeView.Styles>
|
||||||
<Style Selector="TreeView">
|
<Style Selector="TreeView">
|
||||||
<Style Selector="^ TreeViewItem">
|
<Style Selector="^ TreeViewItem">
|
||||||
@@ -160,7 +160,8 @@
|
|||||||
<Grid
|
<Grid
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
Classes.category="{Binding Channel.IsCategory}"
|
Classes.category="{Binding Channel.IsCategory}"
|
||||||
ColumnDefinitions="Auto,*,Auto">
|
ColumnDefinitions="Auto,*,Auto"
|
||||||
|
DoubleTapped="ChannelGrid_OnDoubleTapped">
|
||||||
<Grid.Styles>
|
<Grid.Styles>
|
||||||
<Style Selector="Grid">
|
<Style Selector="Grid">
|
||||||
<Style Selector="^:not(.category)">
|
<Style Selector="^:not(.category)">
|
||||||
@@ -178,10 +179,10 @@
|
|||||||
|
|
||||||
<!-- Channel icon -->
|
<!-- Channel icon -->
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
Classes.voice="{Binding Channel.IsVoice}"
|
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
IsVisible="{Binding !Channel.IsCategory}"
|
Margin="0,0,4,0"
|
||||||
Margin="0,0,4,0">
|
Classes.voice="{Binding Channel.IsVoice}"
|
||||||
|
IsVisible="{Binding !Channel.IsCategory}">
|
||||||
<materialIcons:MaterialIcon.Styles>
|
<materialIcons:MaterialIcon.Styles>
|
||||||
<Style Selector="materialIcons|MaterialIcon">
|
<Style Selector="materialIcons|MaterialIcon">
|
||||||
<Setter Property="Kind" Value="Pound" />
|
<Setter Property="Kind" Value="Pound" />
|
||||||
@@ -195,19 +196,19 @@
|
|||||||
|
|
||||||
<!-- Channel name -->
|
<!-- Channel name -->
|
||||||
<TextBlock
|
<TextBlock
|
||||||
FontSize="14"
|
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="0,12"
|
Margin="0,12"
|
||||||
|
FontSize="14"
|
||||||
Text="{Binding Channel.Name, Mode=OneWay}" />
|
Text="{Binding Channel.Name, Mode=OneWay}" />
|
||||||
|
|
||||||
<!-- Checkmark -->
|
<!-- Checkmark -->
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
Grid.Column="2"
|
Grid.Column="2"
|
||||||
|
Width="24"
|
||||||
Height="24"
|
Height="24"
|
||||||
IsVisible="{Binding $parent[TreeViewItem].IsSelected}"
|
|
||||||
Kind="Check"
|
|
||||||
Margin="16,0"
|
Margin="16,0"
|
||||||
Width="24" />
|
IsVisible="{Binding $parent[TreeViewItem].IsSelected}"
|
||||||
|
Kind="Check" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</TreeDataTemplate>
|
</TreeDataTemplate>
|
||||||
</TreeView.ItemTemplate>
|
</TreeView.ItemTemplate>
|
||||||
@@ -223,14 +224,13 @@
|
|||||||
<TextBlock>
|
<TextBlock>
|
||||||
<InlineUIContainer>
|
<InlineUIContainer>
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
Width="18"
|
||||||
Height="18"
|
Height="18"
|
||||||
Kind="Account"
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
Width="18" />
|
Kind="Account" />
|
||||||
</InlineUIContainer>
|
</InlineUIContainer>
|
||||||
<Run BaselineAlignment="Center" Text="" />
|
<Run Text="" />
|
||||||
<Run
|
<Run
|
||||||
BaselineAlignment="Center"
|
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Text="{Binding LocalizationManager.TokenPersonalHeader}" />
|
Text="{Binding LocalizationManager.TokenPersonalHeader}" />
|
||||||
@@ -254,14 +254,13 @@
|
|||||||
<TextBlock Margin="0,12,0,0">
|
<TextBlock Margin="0,12,0,0">
|
||||||
<InlineUIContainer>
|
<InlineUIContainer>
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
Width="18"
|
||||||
Height="18"
|
Height="18"
|
||||||
Kind="Robot"
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
Width="18" />
|
Kind="Robot" />
|
||||||
</InlineUIContainer>
|
</InlineUIContainer>
|
||||||
<Run BaselineAlignment="Center" Text="" />
|
<Run Text="" />
|
||||||
<Run
|
<Run
|
||||||
BaselineAlignment="Center"
|
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Text="{Binding LocalizationManager.TokenBotHeader}" />
|
Text="{Binding LocalizationManager.TokenBotHeader}" />
|
||||||
@@ -275,11 +274,11 @@
|
|||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
|
Margin="0,12,0,0"
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
FontWeight="Light"
|
FontWeight="Light"
|
||||||
Inlines="{Binding LocalizationManager.TokenHelpText, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
Inlines="{Binding LocalizationManager.TokenHelpText, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||||
LineHeight="23"
|
LineHeight="23"
|
||||||
Margin="0,12,0,0"
|
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
@@ -287,21 +286,21 @@
|
|||||||
|
|
||||||
<!-- Export button -->
|
<!-- Export button -->
|
||||||
<Button
|
<Button
|
||||||
|
Width="56"
|
||||||
|
Height="56"
|
||||||
|
Margin="32,24"
|
||||||
|
Padding="0"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
Background="{DynamicResource MaterialSecondaryMidBrush}"
|
Background="{DynamicResource MaterialSecondaryMidBrush}"
|
||||||
Command="{Binding ExportCommand}"
|
Command="{Binding ExportCommand}"
|
||||||
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
|
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
|
||||||
Height="56"
|
|
||||||
HorizontalAlignment="Right"
|
|
||||||
IsVisible="{Binding $self.IsEffectivelyEnabled}"
|
IsVisible="{Binding $self.IsEffectivelyEnabled}"
|
||||||
Margin="32,24"
|
Theme="{DynamicResource MaterialIconButton}">
|
||||||
Padding="0"
|
|
||||||
Theme="{DynamicResource MaterialIconButton}"
|
|
||||||
VerticalAlignment="Bottom"
|
|
||||||
Width="56">
|
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
|
Width="32"
|
||||||
Height="32"
|
Height="32"
|
||||||
Kind="Download"
|
Kind="Download" />
|
||||||
Width="32" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</Panel>
|
</Panel>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
@@ -36,4 +37,12 @@ public partial class DashboardView : UserControl<DashboardViewModel>
|
|||||||
container.IsSelected = false;
|
container.IsSelected = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ChannelGrid_OnDoubleTapped(object? sender, TappedEventArgs args)
|
||||||
|
{
|
||||||
|
if (DataContext.SelectedChannels.Count != 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
DataContext.ExportCommand.Execute(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,7 @@
|
|||||||
IsVisible="{Binding IsSingleChannel}"
|
IsVisible="{Binding IsSingleChannel}"
|
||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}">
|
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}">
|
||||||
<TextBlock IsVisible="{Binding !!Channels[0].Parent}">
|
<Run Text="{Binding Channels[0].Parent.Name, StringFormat='{}{0} / ', FallbackValue='', TargetNullValue='', Mode=OneWay}" /><Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
|
||||||
<Run Text="{Binding Channels[0].Parent.Name, Mode=OneWay}" />
|
|
||||||
<Run Text="/" />
|
|
||||||
</TextBlock>
|
|
||||||
<Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
|
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -33,30 +33,40 @@
|
|||||||
|
|
||||||
<UniformGrid
|
<UniformGrid
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
Margin="16"
|
Margin="8"
|
||||||
HorizontalAlignment="Right"
|
|
||||||
Columns="{Binding ButtonsCount}">
|
Columns="{Binding ButtonsCount}">
|
||||||
<!-- OK -->
|
<!-- OK -->
|
||||||
<Button
|
<Button
|
||||||
|
Margin="8"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
Command="{Binding CloseCommand}"
|
Command="{Binding CloseCommand}"
|
||||||
Content="{Binding DefaultButtonText}"
|
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
IsVisible="{Binding IsDefaultButtonVisible}"
|
IsVisible="{Binding IsDefaultButtonVisible}"
|
||||||
Theme="{DynamicResource MaterialOutlineButton}">
|
Theme="{DynamicResource MaterialOutlineButton}"
|
||||||
|
ToolTip.Tip="{Binding DefaultButtonText}">
|
||||||
<Button.CommandParameter>
|
<Button.CommandParameter>
|
||||||
<system:Boolean>True</system:Boolean>
|
<system:Boolean>True</system:Boolean>
|
||||||
</Button.CommandParameter>
|
</Button.CommandParameter>
|
||||||
|
<TextBlock
|
||||||
|
Text="{Binding DefaultButtonText}"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Cancel -->
|
<!-- Cancel -->
|
||||||
<Button
|
<Button
|
||||||
Margin="16,0,0,0"
|
Margin="8"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalContentAlignment="Stretch"
|
||||||
Command="{Binding CloseCommand}"
|
Command="{Binding CloseCommand}"
|
||||||
Content="{Binding CancelButtonText}"
|
|
||||||
IsCancel="True"
|
IsCancel="True"
|
||||||
IsVisible="{Binding IsCancelButtonVisible}"
|
IsVisible="{Binding IsCancelButtonVisible}"
|
||||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
Theme="{DynamicResource MaterialOutlineButton}"
|
||||||
|
ToolTip.Tip="{Binding CancelButtonText}">
|
||||||
|
<TextBlock
|
||||||
|
Text="{Binding CancelButtonText}"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</Button>
|
||||||
</UniformGrid>
|
</UniformGrid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -50,8 +50,7 @@
|
|||||||
<!-- Auto-updates -->
|
<!-- Auto-updates -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
IsVisible="{OnPlatform False,
|
IsVisible="{Binding IsAutoUpdateAvailable}"
|
||||||
Windows=True}"
|
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}">
|
ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />
|
||||||
@@ -59,10 +58,13 @@
|
|||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Persist token -->
|
<!-- Persist token -->
|
||||||
<DockPanel
|
<DockPanel Margin="16,8" LastChildFill="False">
|
||||||
Margin="16,8"
|
<ToolTip.Tip>
|
||||||
LastChildFill="False"
|
<TextBlock
|
||||||
ToolTip.Tip="{Binding LocalizationManager.PersistTokenTooltip}">
|
MaxWidth="400"
|
||||||
|
Inlines="{Binding LocalizationManager.PersistTokenTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</ToolTip.Tip>
|
||||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" />
|
||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{EA305DD5-1
|
|||||||
License.txt = License.txt
|
License.txt = License.txt
|
||||||
Readme.md = Readme.md
|
Readme.md = Readme.md
|
||||||
Directory.Build.props = Directory.Build.props
|
Directory.Build.props = Directory.Build.props
|
||||||
|
Directory.Packages.props = Directory.Packages.props
|
||||||
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
|
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
|
||||||
global.json = global.json
|
global.json = global.json
|
||||||
NuGet.config = NuGet.config
|
NuGet.config = NuGet.config
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# DiscordChatExporter
|
# DiscordChatExporter
|
||||||
|
|
||||||
[](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)
|
[](https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md)
|
||||||
[](https://tyrrrz.me/ukraine)
|
[](https://tyrrrz.me/ukraine)
|
||||||
[](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
[](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
||||||
[](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
|
[](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
|
||||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
[](https://hub.docker.com/r/tyrrrz/discordchatexporter)
|
[](https://hub.docker.com/r/tyrrrz/discordchatexporter)
|
||||||
@@ -27,7 +27,7 @@ It works with direct messages, group messages, and server channels, and supports
|
|||||||
|
|
||||||
> 💬 If you want to chat, **join my [Discord server](https://discord.gg/2SUWKFnHSm)**.
|
> 💬 If you want to chat, **join my [Discord server](https://discord.gg/2SUWKFnHSm)**.
|
||||||
|
|
||||||
## Terms of use<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/why-so-political.md)</sup>
|
## Terms of use<sup>[[?]](https://github.com/Tyrrrz/.github/blob/prime/docs/why-so-political.md)</sup>
|
||||||
|
|
||||||
By using this project or its source code, for any purpose and in any shape or form, you grant your **implicit agreement** to all the following statements:
|
By using this project or its source code, for any purpose and in any shape or form, you grant your **implicit agreement** to all the following statements:
|
||||||
|
|
||||||
@@ -43,23 +43,25 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
|
|||||||
- **Graphical user interface** (desktop app):
|
- **Graphical user interface** (desktop app):
|
||||||
- 🟢 **[Stable release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**: look for `DiscordChatExporter.*.zip`
|
- 🟢 **[Stable release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**: look for `DiscordChatExporter.*.zip`
|
||||||
- 🟠 [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml): look for `DiscordChatExporter.*.zip`
|
- 🟠 [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml): look for `DiscordChatExporter.*.zip`
|
||||||
|
- 📦 [Scoop](https://scoop.sh/#/apps?q=DiscordChatExporter&p=1&id=c71b7367623c560a2dc746b9739b9568b79b59ae): `scoop install extras/discordchatexporter` (community-maintained)
|
||||||
|
- 📦 [WinGet](https://winget.run/pkg/Tyrrrz/DiscordChatExporter.GUI): `winget install Tyrrrz.DiscordChatExporter.GUI` (community-maintained)
|
||||||
|
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-gui): `yay -S discord-chat-exporter-gui` (community-maintained)
|
||||||
- **Command-line interface** (terminal app):
|
- **Command-line interface** (terminal app):
|
||||||
- 🟢 **[Stable release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**: look for `DiscordChatExporter.Cli.*.zip`
|
- 🟢 **[Stable release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**: look for `DiscordChatExporter.Cli.*.zip`
|
||||||
- 🟠 [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml): look for `DiscordChatExporter.Cli.*.zip`
|
- 🟠 [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml): look for `DiscordChatExporter.Cli.*.zip`
|
||||||
- 🐋 [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter`
|
- 🐋 [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter`
|
||||||
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli`
|
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `yay -S discord-chat-exporter-cli` (community-maintained)
|
||||||
- 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli`
|
- 📦 [Nix](https://search.nixos.org/packages?show=discordchatexporter-cli): `nix-shell -p discordchatexporter-cli` (community-maintained)
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> To launch the GUI version of the app on MacOS, you need to first remove the downloaded file from quarantine.
|
> To launch the GUI version of the app on MacOS, you may need to first remove the downloaded file from quarantine.
|
||||||
> You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine DiscordChatExporter.app`.
|
> You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine DiscordChatExporter.app`.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
|
> Community-maintained packages are published independently from this repository and may not always be up to date with the latest release.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> AUR and Nix packages linked above are maintained by the community.
|
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
|
||||||
> If you have any issues with them, please contact the corresponding maintainers.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user