From 211db1104161847bb59ecd9d5498fab1ebddb173 Mon Sep 17 00:00:00 2001 From: Philippe Vaucher Date: Wed, 31 Jan 2024 13:39:25 +0100 Subject: [PATCH 001/253] Fix docker user permissions issues (#1194) --- .docs/Docker.md | 11 +++++++++++ DiscordChatExporter.Cli.dockerfile | 11 ++++++----- docker-entrypoint.sh | 10 ++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) create mode 100755 docker-entrypoint.sh diff --git a/.docs/Docker.md b/.docs/Docker.md index b07f0712..ab748687 100644 --- a/.docs/Docker.md +++ b/.docs/Docker.md @@ -61,6 +61,17 @@ For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md). +## Unix permissions issues + +This image was designed with a user running as uid:gid of 1000:1000. + +If your current user has different IDs, and you want to generate files directly editable for your user, you might want to run the container like this: + +```console +mkdir data # or chown -R $(id -u):$(id -g) data +docker run -it --rm -v $PWD/data:/out --user $(id -u):$(id -g) tyrrrz/discordchatexporter:stable export -t TOKEN -g CHANNELID +``` + ## Environment variables DiscordChatExpoter CLI accepts the `DISCORD_TOKEN` environment variable as a fallback for the `--token` option. You can set this variable either with the `--env` Docker option or with a combination of the `--env-file` Docker option and a `.env` file. diff --git a/DiscordChatExporter.Cli.dockerfile b/DiscordChatExporter.Cli.dockerfile index 9bd0a266..9bdfecbd 100644 --- a/DiscordChatExporter.Cli.dockerfile +++ b/DiscordChatExporter.Cli.dockerfile @@ -40,20 +40,21 @@ LABEL org.opencontainers.image.licenses="MIT" # Alpine image doesn't come with the ICU libraries pre-installed, so we need to install them manually. # We need the full ICU data because we allow the user to specify any locale for formatting purposes. -RUN apk add --no-cache icu-libs -RUN apk add --no-cache icu-data-full +RUN apk add --no-cache icu-libs icu-data-full ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false ENV LC_ALL=en_US.UTF-8 ENV LANG=en_US.UTF-8 # Use a non-root user to ensure that the files shared with the host are accessible by the host user # https://github.com/Tyrrrz/DiscordChatExporter/issues/851 -RUN adduser --disabled-password --no-create-home dce -USER dce +# https://github.com/Tyrrrz/DiscordChatExporter/issues/1174 +RUN apk add --no-cache su-exec +RUN addgroup -S -g 1000 dce && adduser -S -H -G dce -u 1000 dce # This directory is exposed to the user for mounting purposes, so it's important that it always # stays the same for backwards compatibility. WORKDIR /out COPY --from=build /tmp/app/DiscordChatExporter.Cli/bin/publish /opt/app -ENTRYPOINT ["/opt/app/DiscordChatExporter.Cli"] \ No newline at end of file +COPY docker-entrypoint.sh /opt/app +ENTRYPOINT ["/opt/app/docker-entrypoint.sh"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 00000000..72480b47 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh + +# If we are root, ensure the files in /out are writable +# by the dce user and restart the process as the dce user +if [ "$(id -u)" = '0' ]; then + chown -R dce:dce /out + exec su-exec dce "$0" "$@" +fi + +exec ./DiscordChatExporter.Cli "$@" From 822bab2365e91d743c9533e315708cac058a40a7 Mon Sep 17 00:00:00 2001 From: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> Date: Wed, 31 Jan 2024 14:43:53 +0200 Subject: [PATCH 002/253] Update action versions --- .github/workflows/docker.yml | 2 +- .github/workflows/main.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c43b586a..1b44e532 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -38,7 +38,7 @@ jobs: --output type=tar,dest=DiscordChatExporter.Cli.Docker.tar - name: Upload artifacts - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # 3.1.3 + uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 with: name: DiscordChatExporter.Cli.Docker path: DiscordChatExporter.Cli.Docker.tar diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2d2df6bd..ee398f46 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -108,7 +108,7 @@ jobs: --configuration Release - name: Upload artifacts - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # 3.1.3 + uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 with: name: ${{ matrix.app }} path: ${{ matrix.app }}/bin/publish/ @@ -163,7 +163,7 @@ jobs: steps: - name: Download artifacts - uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # 3.0.2 + uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 with: name: ${{ matrix.app }} path: ${{ matrix.app }}/ From ba72927c379d0359eaf3cf64ddb339cdb6a4705c Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:32:40 +0200 Subject: [PATCH 003/253] Fix pathing in docker entrypoint --- docker-entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 72480b47..e83ca4d5 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -7,4 +7,4 @@ if [ "$(id -u)" = '0' ]; then exec su-exec dce "$0" "$@" fi -exec ./DiscordChatExporter.Cli "$@" +exec /opt/app/DiscordChatExporter.Cli "$@" From 3a22db14a7be083f7103a929785687d0a076629b Mon Sep 17 00:00:00 2001 From: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> Date: Thu, 1 Feb 2024 21:54:20 +0200 Subject: [PATCH 004/253] Update GitHub Actions --- .github/workflows/docker.yml | 8 ++++---- .github/workflows/main.yml | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1b44e532..ceed2cf9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -24,10 +24,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Install Docker Buildx - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # 3.0.0 + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - name: Build image run: > @@ -56,10 +56,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Install Docker Buildx - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # 3.0.0 + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - name: Login to DockerHub run: > diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ee398f46..c6ff2cf7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,10 +26,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Install .NET - uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0 + uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 with: dotnet-version: 8.0.x @@ -51,10 +51,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Install .NET - uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0 + uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 with: dotnet-version: 8.0.x @@ -72,7 +72,7 @@ jobs: DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover - name: Upload coverage - uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # 3.1.4 + uses: codecov/codecov-action@f30e4959ba63075080d4f7f90cacc18d9f3fafd7 # v4.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -92,10 +92,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Install .NET - uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0 + uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 with: dotnet-version: 8.0.x From 4588bd0496a1809bbba1b415fd8fe631f7b11bb8 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Tue, 13 Feb 2024 18:48:15 +0200 Subject: [PATCH 005/253] Add support for Twitch embed projections (#1199) --- .../Specs/HtmlEmbedSpecs.cs | 20 +++++++ .../Discord/Data/Embeds/Embed.cs | 3 ++ .../Data/Embeds/TwitchClipEmbedProjection.cs | 53 +++++++++++++++++++ .../Exporting/MessageGroupTemplate.cshtml | 9 ++++ .../Exporting/PreambleTemplate.cshtml | 4 ++ 5 files changed, 89 insertions(+) create mode 100644 DiscordChatExporter.Core/Discord/Data/Embeds/TwitchClipEmbedProjection.cs diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs index 8ed0fc56..765bc52d 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs @@ -150,6 +150,26 @@ public class HtmlEmbedSpecs iframeUrl.Should().StartWith("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP"); } + [Fact] + public async Task I_can_export_a_channel_that_contains_a_message_with_a_Twitch_clip_embed() + { + // https://github.com/Tyrrrz/DiscordChatExporter/issues/1196 + + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.EmbedTestCases, + Snowflake.Parse("1207002986128216074") + ); + + // Assert + var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src"); + iframeUrl + .Should() + .StartWith( + "https://clips.twitch.tv/embed?clip=SpicyMildCiderThisIsSparta--PQhbllrvej_Ee7v" + ); + } + [Fact] public async Task I_can_export_a_channel_that_contains_a_message_with_a_YouTube_video_embed() { diff --git a/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs b/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs index 1d2d0b35..ccc42d92 100644 --- a/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs +++ b/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs @@ -31,6 +31,9 @@ public partial record Embed( public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() => SpotifyTrackEmbedProjection.TryResolve(this); + public TwitchClipEmbedProjection? TryGetTwitchClip() => + TwitchClipEmbedProjection.TryResolve(this); + public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() => YouTubeVideoEmbedProjection.TryResolve(this); } diff --git a/DiscordChatExporter.Core/Discord/Data/Embeds/TwitchClipEmbedProjection.cs b/DiscordChatExporter.Core/Discord/Data/Embeds/TwitchClipEmbedProjection.cs new file mode 100644 index 00000000..3c7da139 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Embeds/TwitchClipEmbedProjection.cs @@ -0,0 +1,53 @@ +using System.Text.RegularExpressions; + +namespace DiscordChatExporter.Core.Discord.Data.Embeds; + +public partial record TwitchClipEmbedProjection(string ClipId) +{ + public string Url => $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost"; +} + +public partial record TwitchClipEmbedProjection +{ + private static string? TryParseClipId(string embedUrl) + { + // https://clips.twitch.tv/SpookyTenuousPidgeonPanicVis + { + var clipId = Regex + .Match(embedUrl, @"clips\.twitch\.tv/(.*?)(?:\?|&|/|$)") + .Groups[1] + .Value; + + if (!string.IsNullOrWhiteSpace(clipId)) + return clipId; + } + + // https://twitch.tv/clip/SpookyTenuousPidgeonPanicVis + { + var clipId = Regex + .Match(embedUrl, @"twitch\.tv/clip/(.*?)(?:\?|&|/|$)") + .Groups[1] + .Value; + + if (!string.IsNullOrWhiteSpace(clipId)) + return clipId; + } + + return null; + } + + public static TwitchClipEmbedProjection? TryResolve(Embed embed) + { + if (embed.Kind != EmbedKind.Video) + return null; + + if (string.IsNullOrWhiteSpace(embed.Url)) + return null; + + var clipId = TryParseClipId(embed.Url); + if (string.IsNullOrWhiteSpace(clipId)) + return null; + + return new TwitchClipEmbedProjection(clipId); + } +} diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index 92f8a43c..45f47386 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -365,6 +365,15 @@ } + // Twitch embed + else if (embed.TryGetTwitchClip() is { } twitchClipEmbed) + { +
+
+ +
+
+ } // YouTube embed else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed) { diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index a71fd831..9e746e16 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -702,6 +702,10 @@ .chatlog__embed-spotify { border: 0; } + + .chatlog__embed-twitch { + border: 0; + } .chatlog__embed-youtube-container { margin-top: 0.6rem; From 7c04db40f80dbe40e38fcffe318a1cb440c595c8 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Tue, 13 Feb 2024 18:59:51 +0200 Subject: [PATCH 006/253] Skip Twitch clip projections because they can't be rendered locally (#1200) --- .../Specs/HtmlEmbedSpecs.cs | 2 +- .../Exporting/MessageGroupTemplate.cshtml | 14 ++++---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs index 765bc52d..9de8c0b9 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs @@ -150,7 +150,7 @@ public class HtmlEmbedSpecs iframeUrl.Should().StartWith("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP"); } - [Fact] + [Fact(Skip = "Twitch does not allow embeds from inside local HTML files")] public async Task I_can_export_a_channel_that_contains_a_message_with_a_Twitch_clip_embed() { // https://github.com/Tyrrrz/DiscordChatExporter/issues/1196 diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index 45f47386..e0730553 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -365,15 +365,6 @@ } - // Twitch embed - else if (embed.TryGetTwitchClip() is { } twitchClipEmbed) - { -
-
- -
-
- } // YouTube embed else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed) { @@ -457,7 +448,10 @@ } // Generic video embed - else if (embed.Kind == EmbedKind.Video && !string.IsNullOrWhiteSpace(embed.Url)) + else if (embed.Kind == EmbedKind.Video + && !string.IsNullOrWhiteSpace(embed.Url) + // Twitch clips cannot be embedded in local HTML files + && embed.TryGetTwitchClip() is null) { var embedVideoUrl = embed.Video?.ProxyUrl ?? embed.Video?.Url ?? From c0ea57e9b1ccf09df5cfe024b29815cae2f36bbf Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Tue, 13 Feb 2024 19:13:38 +0200 Subject: [PATCH 007/253] Shorten error messages for when attempting to export a period with no messages (#1201) --- .../Exporting/ChannelExporter.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index dc60c1dc..805e6b56 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -20,8 +20,8 @@ public class ChannelExporter(DiscordClient discord) if (request.Channel.Kind == ChannelKind.GuildForum) { throw new DiscordChatExporterException( - $"Channel '{request.Channel.Name}' (#{request.Channel.Id}) " - + $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) " + $"Channel '{request.Channel.Name}' " + + $"of guild '{request.Guild.Name}' " + $"is a forum and cannot be exported directly. " + "You need to pull its threads and export them individually." ); @@ -31,8 +31,8 @@ public class ChannelExporter(DiscordClient discord) if (request.Channel.IsEmpty) { throw new DiscordChatExporterException( - $"Channel '{request.Channel.Name}' (#{request.Channel.Id}) " - + $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) " + $"Channel '{request.Channel.Name}' " + + $"of guild '{request.Guild.Name}' " + $"does not contain any messages." ); } @@ -41,8 +41,8 @@ public class ChannelExporter(DiscordClient discord) if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value)) { throw new DiscordChatExporterException( - $"Channel '{request.Channel.Name}' (#{request.Channel.Id}) " - + $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) " + $"Channel '{request.Channel.Name}' " + + $"of guild '{request.Guild.Name}' " + $"does not contain any messages within the specified period." ); } @@ -54,8 +54,8 @@ public class ChannelExporter(DiscordClient discord) ) { throw new DiscordChatExporterException( - $"Channel '{request.Channel.Name}' (#{request.Channel.Id}) " - + $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) " + $"Channel '{request.Channel.Name}' " + + $"of guild '{request.Guild.Name}' " + $"does not contain any messages within the specified period." ); } From 0139eeaeed50502cd60ab36e98447d5c6ee85fe8 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Sat, 2 Mar 2024 04:59:49 +0200 Subject: [PATCH 008/253] Add `DiscordChatExporter.Cli.sh` for simpler usage from shell (#1205) --- DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj | 4 ++++ DiscordChatExporter.Cli/DiscordChatExporter.Cli.sh | 1 + 2 files changed, 5 insertions(+) create mode 100644 DiscordChatExporter.Cli/DiscordChatExporter.Cli.sh diff --git a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj index 4d012b99..24e48c4d 100644 --- a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj +++ b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj @@ -5,6 +5,10 @@ ..\favicon.ico + + + + diff --git a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.sh b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.sh new file mode 100644 index 00000000..4154878a --- /dev/null +++ b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.sh @@ -0,0 +1 @@ +dotnet DiscordChatExporter.Cli.dll "$@" \ No newline at end of file From 0e87098889d8e0abed4f81a40073f6c1e7cc2348 Mon Sep 17 00:00:00 2001 From: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> Date: Tue, 5 Mar 2024 19:39:06 +0200 Subject: [PATCH 009/253] Make the DCE.CLI unix entrypoint script work regardless of current working directory --- DiscordChatExporter.Cli/DiscordChatExporter.Cli.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.sh b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.sh index 4154878a..4d6611de 100644 --- a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.sh +++ b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.sh @@ -1 +1 @@ -dotnet DiscordChatExporter.Cli.dll "$@" \ No newline at end of file +dotnet "$(dirname "$0")/DiscordChatExporter.Cli.dll" "$@" \ No newline at end of file From 47d560229dc31195c33025dd747a8273d3b8b0d2 Mon Sep 17 00:00:00 2001 From: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> Date: Mon, 18 Mar 2024 03:12:31 +0200 Subject: [PATCH 010/253] Set the executable permission on CLI entrypoint script before packaging for release --- .github/workflows/main.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c6ff2cf7..3367db83 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -167,11 +167,16 @@ jobs: with: name: ${{ matrix.app }} path: ${{ matrix.app }}/ + + - name: Configure permissions + # Remove this when the GUI also becomes cross-platform + if: ${{ matrix.app == 'DiscordChatExporter.Cli' }} + run: chmod +x ${{ matrix.app }}/${{ matrix.app }}.sh - name: Create package # Change into the artifacts directory to avoid including the directory itself in the zip archive working-directory: ${{ matrix.app }}/ - run: zip -r ../${{ matrix.asset }}.zip . + run: zip -rX ../${{ matrix.asset }}.zip . - name: Upload release asset env: From 72f02cd9a0ebcce630a3a106cddc808cd87e6d48 Mon Sep 17 00:00:00 2001 From: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> Date: Mon, 18 Mar 2024 03:20:46 +0200 Subject: [PATCH 011/253] Update docs with the easier way to run the CLI --- .docs/Linux.md | 20 +++++++++++++------- .docs/MacOS.md | 12 ++++++++++++ .docs/Using-the-CLI.md | 14 +++++++++++++- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.docs/Linux.md b/.docs/Linux.md index 7a37ff72..740b6b1b 100644 --- a/.docs/Linux.md +++ b/.docs/Linux.md @@ -8,19 +8,25 @@ Please follow the [instructions provided here](Dotnet.md). 1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder. 2. Open Terminal. -3. `cd` into the extracted folder. You can do this in Terminal by typing `cd`, then press the SPACE key, drag and drop the extracted folder into the Terminal window, and press the ENTER key. +3. Change the working directory into the extracted folder. You can do this in Terminal by typing `cd`, then press the SPACE key, drag and drop the extracted folder into the Terminal window, and press the ENTER key. 4. Replace `TOKEN` and `CHANNEL`, then execute this command to export: +```console +./DiscordChatExporter.Cli.sh export -t TOKEN -c CHANNEL +``` + +If the above command throws a "Permission denied" error, use `chmod` to fix the permissions: + +```console +chmod +x DiscordChatExporter.Cli.sh +``` + +Alternatively, if the script doesn't work, you can run the following command to run the application directly: + ```console dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL ``` -If the above command throws a "Permission denied" error, execute this command to fix the permissions: - -```console -chmod 644 *.dll DiscordChatExporter.* -``` - > [How to get Token and Channel IDs](Token-and-IDs.md). There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started. diff --git a/.docs/MacOS.md b/.docs/MacOS.md index 8b949e4c..56bcd522 100644 --- a/.docs/MacOS.md +++ b/.docs/MacOS.md @@ -13,6 +13,18 @@ Please follow the [instructions provided here](Dotnet.md). 3. In the Terminal window, type `cd` , press the SPACE key, then drag and drop the extracted folder into the window, then press the RETURN key. 4. Execute the following command to export, replacing `TOKEN` and `CHANNEL` with your own values: +```console +./DiscordChatExporter.Cli.sh export -t TOKEN -c CHANNEL +``` + +If the above command throws a "Permission denied" error, use `chmod` to fix the permissions: + +```console +chmod +x DiscordChatExporter.Cli.sh +``` + +Alternatively, if the script doesn't work, you can run the following command to run the application directly: + ```console dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL ``` diff --git a/.docs/Using-the-CLI.md b/.docs/Using-the-CLI.md index b52d2449..79db0d50 100644 --- a/.docs/Using-the-CLI.md +++ b/.docs/Using-the-CLI.md @@ -28,7 +28,19 @@ You can also drag and drop the folder on **every platform**. Now we're ready to run the commands. The examples on this page follow the Windows file path format, change the file paths according to your system. -Type the following in Command Prompt (Terminal), then press ENTER to run it. This will list DCE's options. +On **Windows**, type the following command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and options. + +```console +DiscordChatExporter.Cli.exe +``` + +On **macOS** and **Linux**, run the following instead: + +```console +./DiscordChatExporter.Cli.sh +``` + +If either of the above approaches don't work for you, you can also run **DiscordChatExporter** directly like this, regardless of the platform: ```console dotnet DiscordChatExporter.Cli.dll From 6d1e8c2e02fa2f051f1360d5207b1c18290bff48 Mon Sep 17 00:00:00 2001 From: xs2237 Date: Fri, 22 Mar 2024 00:29:14 +0900 Subject: [PATCH 012/253] Remove unnecessary whitespaces at the end of reactions in PlainText (#1213) --- .../Exporting/PlainTextMessageWriter.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs b/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs index 38206ac5..85b46a64 100644 --- a/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data.Embeds; +using DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Exporting; @@ -172,18 +173,21 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context) await _writer.WriteLineAsync("{Reactions}"); - foreach (var reaction in reactions) + foreach (var (reaction, i) in reactions.WithIndex()) { cancellationToken.ThrowIfCancellationRequested(); + if (i > 0) + { + await _writer.WriteAsync(' '); + } + await _writer.WriteAsync(reaction.Emoji.Name); if (reaction.Count > 1) { await _writer.WriteAsync($" ({reaction.Count})"); } - - await _writer.WriteAsync(' '); } await _writer.WriteLineAsync(); From 12b590d9f97009a4638d45af7950d5d19aec96fc Mon Sep 17 00:00:00 2001 From: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> Date: Thu, 28 Mar 2024 03:22:44 +0200 Subject: [PATCH 013/253] Use `[]` in place of `Array.Empty()` --- DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs | 3 +-- DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs | 4 ++-- DiscordChatExporter.Core/Discord/Data/Member.cs | 8 +++----- DiscordChatExporter.Core/Discord/Data/Message.cs | 10 +++++----- DiscordChatExporter.Core/Exporting/ExportContext.cs | 2 +- 5 files changed, 12 insertions(+), 15 deletions(-) diff --git a/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs b/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs index a430cac1..60b152fe 100644 --- a/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs +++ b/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs @@ -7,7 +7,6 @@ using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Converters; using DiscordChatExporter.Cli.Commands.Shared; using DiscordChatExporter.Core.Discord; -using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Cli.Commands; @@ -59,7 +58,7 @@ public class GetChannelsCommand : DiscordCommandBase ) .OrderBy(c => c.Name) .ToArray() - : Array.Empty(); + : []; foreach (var channel in channels) { diff --git a/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs b/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs index ccc42d92..8779be70 100644 --- a/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs +++ b/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs @@ -63,7 +63,7 @@ public partial record Embed json.GetPropertyOrNull("fields") ?.EnumerateArrayOrNull() ?.Select(EmbedField.Parse) - .ToArray() ?? Array.Empty(); + .ToArray() ?? []; var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse); @@ -78,7 +78,7 @@ public partial record Embed json.GetPropertyOrNull("image") ?.Pipe(EmbedImage.Parse) .ToSingletonEnumerable() - .ToArray() ?? Array.Empty(); + .ToArray() ?? []; var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse); diff --git a/DiscordChatExporter.Core/Discord/Data/Member.cs b/DiscordChatExporter.Core/Discord/Data/Member.cs index ce92e569..d023f373 100644 --- a/DiscordChatExporter.Core/Discord/Data/Member.cs +++ b/DiscordChatExporter.Core/Discord/Data/Member.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Text.Json; using DiscordChatExporter.Core.Discord.Data.Common; @@ -21,8 +20,7 @@ public partial record Member( public partial record Member { - public static Member CreateFallback(User user) => - new(user, null, null, Array.Empty()); + public static Member CreateFallback(User user) => new(user, null, null, []); public static Member Parse(JsonElement json, Snowflake? guildId = null) { @@ -34,7 +32,7 @@ public partial record Member ?.EnumerateArray() .Select(j => j.GetNonWhiteSpaceString()) .Select(Snowflake.Parse) - .ToArray() ?? Array.Empty(); + .ToArray() ?? []; var avatarUrl = guildId is not null ? json.GetPropertyOrNull("avatar") diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index f7616473..a7b19ed1 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -144,28 +144,28 @@ public partial record Message json.GetPropertyOrNull("attachments") ?.EnumerateArrayOrNull() ?.Select(Attachment.Parse) - .ToArray() ?? Array.Empty(); + .ToArray() ?? []; var embeds = NormalizeEmbeds( json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() - ?? Array.Empty() + ?? [] ); var stickers = json.GetPropertyOrNull("sticker_items") ?.EnumerateArrayOrNull() ?.Select(Sticker.Parse) - .ToArray() ?? Array.Empty(); + .ToArray() ?? []; var reactions = json.GetPropertyOrNull("reactions") ?.EnumerateArrayOrNull() ?.Select(Reaction.Parse) - .ToArray() ?? Array.Empty(); + .ToArray() ?? []; var mentionedUsers = json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() - ?? Array.Empty(); + ?? []; var messageReference = json.GetPropertyOrNull("message_reference") ?.Pipe(MessageReference.Parse); diff --git a/DiscordChatExporter.Core/Exporting/ExportContext.cs b/DiscordChatExporter.Core/Exporting/ExportContext.cs index 224c5aa1..c4dca3fe 100644 --- a/DiscordChatExporter.Core/Exporting/ExportContext.cs +++ b/DiscordChatExporter.Core/Exporting/ExportContext.cs @@ -97,7 +97,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request) .Select(TryGetRole) .WhereNotNull() .OrderByDescending(r => r.Position) - .ToArray() ?? Array.Empty(); + .ToArray() ?? []; public Color? TryGetUserColor(Snowflake id) => GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault(); From 74f99b4e59fd0a8efd42748f86e752b85ab81024 Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti Date: Sun, 21 Apr 2024 17:32:57 +0200 Subject: [PATCH 014/253] Switched from SemaphoreSlim lock to AsyncNonKeyedLocker (#1184) --- .../DiscordChatExporter.Core.csproj | 4 ++-- .../ViewModels/Framework/DialogManager.cs | 11 +++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj index 709517fa..8c2b575b 100644 --- a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj +++ b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj @@ -1,7 +1,7 @@ - + @@ -12,4 +12,4 @@ - \ No newline at end of file + diff --git a/DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs b/DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs index 15958817..1c9fb073 100644 --- a/DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs +++ b/DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs @@ -1,7 +1,7 @@ using System; using System.IO; -using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using MaterialDesignThemes.Wpf; using Microsoft.Win32; using Stylet; @@ -10,7 +10,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework; public class DialogManager(IViewManager viewManager) : IDisposable { - private readonly SemaphoreSlim _dialogLock = new(1, 1); + private readonly AsyncNonKeyedLocker _dialogLock = new(); public async ValueTask ShowDialogAsync(DialogScreen dialogScreen) { @@ -34,16 +34,11 @@ public class DialogManager(IViewManager viewManager) : IDisposable dialogScreen.Closed += OnScreenClosed; } - await _dialogLock.WaitAsync(); - try + using (await _dialogLock.LockAsync()) { await DialogHost.Show(view, OnDialogOpened); return dialogScreen.DialogResult; } - finally - { - _dialogLock.Release(); - } } public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "") From b9c1c47474ec5f5374cdd3cf53a79512ccfe34d2 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Sat, 27 Apr 2024 04:17:46 +0300 Subject: [PATCH 015/253] Migrate to Avalonia (#1220) --- .github/workflows/main.yml | 1 + .../DiscordChatExporter.Cli.Tests.csproj | 13 +- .../Infra/ExportWrapper.cs | 26 +- .../Specs/DateRangeSpecs.cs | 18 +- .../Specs/HtmlEmbedSpecs.cs | 55 +- .../DiscordChatExporter.Cli.csproj | 4 +- .../Discord/Data/ChannelNode.cs | 21 + .../Discord/Data/Message.cs | 19 +- .../Discord/DiscordClient.cs | 21 +- .../DiscordChatExporter.Core.csproj | 12 +- .../Exporting/ExportAssetDownloader.cs | 19 +- .../Exporting/ExportContext.cs | 3 +- .../Filtering/ContainsMessageFilter.cs | 13 +- .../Filtering/MentionsMessageFilter.cs | 11 +- .../Filtering/Parsing/FilterGrammar.cs | 4 +- .../Filtering/ReactionMessageFilter.cs | 9 +- .../Exporting/HtmlMarkdownVisitor.cs | 30 +- DiscordChatExporter.Core/Utils/Http.cs | 9 +- DiscordChatExporter.Gui/App.axaml | 135 +++++ DiscordChatExporter.Gui/App.axaml.cs | 110 ++++ DiscordChatExporter.Gui/App.xaml | 543 ------------------ DiscordChatExporter.Gui/App.xaml.cs | 52 -- .../ChannelMultiSelectionListBoxBehavior.cs | 5 - .../MultiSelectionListBoxBehavior.cs | 104 ---- DiscordChatExporter.Gui/Bootstrapper.cs | 45 -- .../Converters/ChannelToGroupKeyConverter.cs | 35 -- ...hannelToHierarchicalNameStringConverter.cs | 25 + .../DateTimeOffsetToDateTimeConverter.cs | 31 - .../ExportFormatToStringConverter.cs | 5 +- .../Converters/InverseBoolConverter.cs | 21 - ... => LocaleToDisplayNameStringConverter.cs} | 7 +- ...=> SnowflakeToTimestampStringConverter.cs} | 9 +- .../Converters/TimeSpanToDateTimeConverter.cs | 25 - .../DiscordChatExporter.Gui.csproj | 26 +- DiscordChatExporter.Gui/FodyWeavers.xml | 4 - DiscordChatExporter.Gui/FodyWeavers.xsd | 74 --- .../Framework/DialogManager.cs | 88 +++ .../Framework/DialogVIewModelBase.cs | 25 + .../Framework/SnackbarManager.cs | 34 ++ .../Framework/UserControl.cs | 18 + .../Framework/ViewManager.cs | 37 ++ .../Framework/ViewModelBase.cs | 19 + .../Framework/ViewModelManager.cs | 53 ++ DiscordChatExporter.Gui/Framework/Window.cs | 18 + DiscordChatExporter.Gui/Program.cs | 51 ++ .../Services/SettingsService.cs | 87 +-- .../Services/UpdateService.cs | 4 + DiscordChatExporter.Gui/Utils/Disposable.cs | 10 + .../Utils/DisposableCollector.cs | 28 + .../Utils/Extensions/AvaloniaExtensions.cs | 34 ++ .../Utils/Extensions/DisposableExtensions.cs | 28 + .../NotifyPropertyChangedExtensions.cs | 60 ++ .../Utils/Internationalization.cs | 2 + DiscordChatExporter.Gui/Utils/MediaColor.cs | 8 - .../Utils/NativeMethods.cs | 12 + DiscordChatExporter.Gui/Utils/ProcessEx.cs | 6 +- .../Components/DashboardViewModel.cs | 215 +++---- .../Dialogs/ExportSetupViewModel.cs | 174 +++--- .../ViewModels/Dialogs/MessageBoxViewModel.cs | 56 +- .../ViewModels/Dialogs/SettingsViewModel.cs | 65 ++- .../ViewModels/Framework/DialogManager.cs | 67 --- .../ViewModels/Framework/DialogScreen.cs | 19 - .../ViewModels/Framework/IViewModelFactory.cs | 16 - .../ViewModels/MainViewModel.cs | 127 ++++ .../Messages/NotificationMessage.cs | 3 - .../ViewModels/RootViewModel.cs | 147 ----- .../Views/Components/DashboardView.axaml | 363 ++++++++++++ .../Views/Components/DashboardView.axaml.cs | 37 ++ .../Views/Components/DashboardView.xaml | 440 -------------- .../Views/Components/DashboardView.xaml.cs | 9 - .../Views/Controls/HyperLink.axaml | 19 + .../Views/Controls/HyperLink.axaml.cs | 49 ++ .../Views/Controls/RevealablePasswordBox.xaml | 24 - .../Controls/RevealablePasswordBox.xaml.cs | 40 -- .../Views/Dialogs/ExportSetupView.axaml | 343 +++++++++++ .../Views/Dialogs/ExportSetupView.axaml.cs | 13 + .../Views/Dialogs/ExportSetupView.xaml | 365 ------------ .../Views/Dialogs/ExportSetupView.xaml.cs | 9 - ...ssageBoxView.xaml => MessageBoxView.axaml} | 40 +- .../Views/Dialogs/MessageBoxView.axaml.cs | 9 + .../Views/Dialogs/MessageBoxView.xaml.cs | 9 - .../Views/Dialogs/SettingsView.axaml | 133 +++++ .../Views/Dialogs/SettingsView.axaml.cs | 27 + .../Views/Dialogs/SettingsView.xaml | 192 ------- .../Views/Dialogs/SettingsView.xaml.cs | 17 - DiscordChatExporter.Gui/Views/MainView.axaml | 28 + .../Views/MainView.axaml.cs | 13 + DiscordChatExporter.Gui/Views/RootView.xaml | 34 -- .../Views/RootView.xaml.cs | 9 - 89 files changed, 2467 insertions(+), 2810 deletions(-) create mode 100644 DiscordChatExporter.Core/Discord/Data/ChannelNode.cs create mode 100644 DiscordChatExporter.Gui/App.axaml create mode 100644 DiscordChatExporter.Gui/App.axaml.cs delete mode 100644 DiscordChatExporter.Gui/App.xaml delete mode 100644 DiscordChatExporter.Gui/App.xaml.cs delete mode 100644 DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs delete mode 100644 DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs delete mode 100644 DiscordChatExporter.Gui/Bootstrapper.cs delete mode 100644 DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs create mode 100644 DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs delete mode 100644 DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs delete mode 100644 DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs rename DiscordChatExporter.Gui/Converters/{LocaleToDisplayNameConverter.cs => LocaleToDisplayNameStringConverter.cs} (71%) rename DiscordChatExporter.Gui/Converters/{SnowflakeToDateTimeOffsetConverter.cs => SnowflakeToTimestampStringConverter.cs} (59%) delete mode 100644 DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs delete mode 100644 DiscordChatExporter.Gui/FodyWeavers.xml delete mode 100644 DiscordChatExporter.Gui/FodyWeavers.xsd create mode 100644 DiscordChatExporter.Gui/Framework/DialogManager.cs create mode 100644 DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs create mode 100644 DiscordChatExporter.Gui/Framework/SnackbarManager.cs create mode 100644 DiscordChatExporter.Gui/Framework/UserControl.cs create mode 100644 DiscordChatExporter.Gui/Framework/ViewManager.cs create mode 100644 DiscordChatExporter.Gui/Framework/ViewModelBase.cs create mode 100644 DiscordChatExporter.Gui/Framework/ViewModelManager.cs create mode 100644 DiscordChatExporter.Gui/Framework/Window.cs create mode 100644 DiscordChatExporter.Gui/Program.cs create mode 100644 DiscordChatExporter.Gui/Utils/Disposable.cs create mode 100644 DiscordChatExporter.Gui/Utils/DisposableCollector.cs create mode 100644 DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs create mode 100644 DiscordChatExporter.Gui/Utils/Extensions/DisposableExtensions.cs create mode 100644 DiscordChatExporter.Gui/Utils/Extensions/NotifyPropertyChangedExtensions.cs delete mode 100644 DiscordChatExporter.Gui/Utils/MediaColor.cs create mode 100644 DiscordChatExporter.Gui/Utils/NativeMethods.cs delete mode 100644 DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs delete mode 100644 DiscordChatExporter.Gui/ViewModels/Framework/DialogScreen.cs delete mode 100644 DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs create mode 100644 DiscordChatExporter.Gui/ViewModels/MainViewModel.cs delete mode 100644 DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs delete mode 100644 DiscordChatExporter.Gui/ViewModels/RootViewModel.cs create mode 100644 DiscordChatExporter.Gui/Views/Components/DashboardView.axaml create mode 100644 DiscordChatExporter.Gui/Views/Components/DashboardView.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/Components/DashboardView.xaml delete mode 100644 DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs create mode 100644 DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml create mode 100644 DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml delete mode 100644 DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml.cs create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.xaml delete mode 100644 DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.xaml.cs rename DiscordChatExporter.Gui/Views/Dialogs/{MessageBoxView.xaml => MessageBoxView.axaml} (54%) create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/MessageBoxView.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/Dialogs/MessageBoxView.xaml.cs create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml create mode 100644 DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/Dialogs/SettingsView.xaml delete mode 100644 DiscordChatExporter.Gui/Views/Dialogs/SettingsView.xaml.cs create mode 100644 DiscordChatExporter.Gui/Views/MainView.axaml create mode 100644 DiscordChatExporter.Gui/Views/MainView.axaml.cs delete mode 100644 DiscordChatExporter.Gui/Views/RootView.xaml delete mode 100644 DiscordChatExporter.Gui/Views/RootView.xaml.cs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3367db83..5d57fce7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -106,6 +106,7 @@ jobs: -p:CSharpier_Bypass=true --output ${{ matrix.app }}/bin/publish/ --configuration Release + --use-current-runtime - name: Upload artifacts uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 diff --git a/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj b/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj index dff62425..98b64906 100644 --- a/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj +++ b/DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj @@ -11,19 +11,18 @@ - - - + + + - - - - + + + diff --git a/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs b/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs index d0a389f7..aa3c37a8 100644 --- a/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs +++ b/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs @@ -93,13 +93,12 @@ public static class ExportWrapper Snowflake messageId ) { - var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault( - e => - string.Equals( - e.GetAttribute("data-message-id"), - messageId.ToString(), - StringComparison.OrdinalIgnoreCase - ) + var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e => + string.Equals( + e.GetAttribute("data-message-id"), + messageId.ToString(), + StringComparison.OrdinalIgnoreCase + ) ); if (message is null) @@ -117,13 +116,12 @@ public static class ExportWrapper Snowflake messageId ) { - var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault( - j => - string.Equals( - j.GetProperty("id").GetString(), - messageId.ToString(), - StringComparison.OrdinalIgnoreCase - ) + var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j => + string.Equals( + j.GetProperty("id").GetString(), + messageId.ToString(), + StringComparison.OrdinalIgnoreCase + ) ); if (message.ValueKind == JsonValueKind.Undefined) diff --git a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs index 72577112..41e8cc90 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs @@ -53,10 +53,8 @@ public class DateRangeSpecs new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero) ], o => - o.Using( - ctx => - ctx.Subject.Should() - .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) + o.Using(ctx => + ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) ) .WhenTypeIs() ); @@ -97,10 +95,8 @@ public class DateRangeSpecs new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero) ], o => - o.Using( - ctx => - ctx.Subject.Should() - .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) + o.Using(ctx => + ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) ) .WhenTypeIs() ); @@ -144,10 +140,8 @@ public class DateRangeSpecs new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero) ], o => - o.Using( - ctx => - ctx.Subject.Should() - .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) + o.Using(ctx => + ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) ) .WhenTypeIs() ); diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs index 9de8c0b9..207e8041 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs @@ -90,12 +90,11 @@ public class HtmlEmbedSpecs .QuerySelectorAll("source") .Select(e => e.GetAttribute("src")) .WhereNotNull() - .Where( - s => - s.Contains( - "i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4", - StringComparison.Ordinal - ) + .Where(s => + s.Contains( + "i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4", + StringComparison.Ordinal + ) ) .Should() .ContainSingle(); @@ -205,42 +204,38 @@ public class HtmlEmbedSpecs imageUrls .Should() - .Contain( - u => - u.EndsWith( - "https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png", - StringComparison.Ordinal - ) + .Contain(u => + u.EndsWith( + "https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png", + StringComparison.Ordinal + ) ); imageUrls .Should() - .Contain( - u => - u.EndsWith( - "https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png", - StringComparison.Ordinal - ) + .Contain(u => + u.EndsWith( + "https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png", + StringComparison.Ordinal + ) ); imageUrls .Should() - .Contain( - u => - u.EndsWith( - "https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png", - StringComparison.Ordinal - ) + .Contain(u => + u.EndsWith( + "https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png", + StringComparison.Ordinal + ) ); imageUrls .Should() - .Contain( - u => - u.EndsWith( - "https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png", - StringComparison.Ordinal - ) + .Contain(u => + u.EndsWith( + "https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png", + StringComparison.Ordinal + ) ); message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle(); diff --git a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj index 24e48c4d..95ebf182 100644 --- a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj +++ b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj @@ -11,9 +11,9 @@ - + - + diff --git a/DiscordChatExporter.Core/Discord/Data/ChannelNode.cs b/DiscordChatExporter.Core/Discord/Data/ChannelNode.cs new file mode 100644 index 00000000..a465aec8 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/ChannelNode.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Linq; + +namespace DiscordChatExporter.Core.Discord.Data; + +public record ChannelNode(Channel Channel, IReadOnlyList Children) +{ + public static IReadOnlyList BuildTree(IReadOnlyList channels) + { + IReadOnlyList GetChildren(Channel parent) => + channels + .Where(c => c.Parent?.Id == parent.Id) + .Select(c => new ChannelNode(c, GetChildren(c))) + .ToArray(); + + return channels + .Where(c => c.Parent is null) + .Select(c => new ChannelNode(c, GetChildren(c))) + .ToArray(); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index a7b19ed1..6fa13c1a 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -83,16 +83,15 @@ public partial record Message // Find embeds with the same URL that only contain a single image and nothing else var trailingEmbeds = embeds .Skip(i + 1) - .TakeWhile( - e => - e.Url == embed.Url - && e.Timestamp is null - && e.Author is null - && e.Color is null - && string.IsNullOrWhiteSpace(e.Description) - && !e.Fields.Any() - && e.Images.Count == 1 - && e.Footer is null + .TakeWhile(e => + e.Url == embed.Url + && e.Timestamp is null + && e.Author is null + && e.Color is null + && string.IsNullOrWhiteSpace(e.Description) + && !e.Fields.Any() + && e.Images.Count == 1 + && e.Footer is null ) .ToArray(); diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index a3f14cb1..4b85fecd 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -66,12 +66,12 @@ public class DiscordClient(string token) if (remainingRequestCount <= 0 && resetAfterDelay is not null) { var delay = - // Adding a small buffer to the reset time reduces the chance of getting - // rate limited again, because it allows for more requests to be released. - (resetAfterDelay.Value + TimeSpan.FromSeconds(1)) - // Sometimes Discord returns an absurdly high value for the reset time, which - // is not actually enforced by the server. So we cap it at a reasonable value. - .Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60)); + // Adding a small buffer to the reset time reduces the chance of getting + // rate limited again, because it allows for more requests to be released. + (resetAfterDelay.Value + TimeSpan.FromSeconds(1)) + // Sometimes Discord returns an absurdly high value for the reset time, which + // is not actually enforced by the server. So we cap it at a reasonable value. + .Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60)); await Task.Delay(delay, innerCancellationToken); } @@ -152,8 +152,13 @@ public class DiscordClient(string token) _ => throw new DiscordChatExporterException( $""" - Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}. - Response content: {await response.Content.ReadAsStringAsync(cancellationToken)} + Request to '{url}' failed: {response + .StatusCode.ToString() + .ToSpaceSeparatedWords() + .ToLowerInvariant()}. + Response content: {await response.Content.ReadAsStringAsync( + cancellationToken + )} """, true ) diff --git a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj index 8c2b575b..2813f89b 100644 --- a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj +++ b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj @@ -2,14 +2,14 @@ - + - - + + - - + + - + \ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs b/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs index fe957ed2..35620d4b 100644 --- a/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs +++ b/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs @@ -58,16 +58,15 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse) { var lastModified = response .Content.Headers.TryGetValue("Last-Modified") - ?.Pipe( - s => - DateTimeOffset.TryParse( - s, - CultureInfo.InvariantCulture, - DateTimeStyles.None, - out var instant - ) - ? instant - : (DateTimeOffset?)null + ?.Pipe(s => + DateTimeOffset.TryParse( + s, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var instant + ) + ? instant + : (DateTimeOffset?)null ); if (lastModified is not null) diff --git a/DiscordChatExporter.Core/Exporting/ExportContext.cs b/DiscordChatExporter.Core/Exporting/ExportContext.cs index c4dca3fe..c3d43f62 100644 --- a/DiscordChatExporter.Core/Exporting/ExportContext.cs +++ b/DiscordChatExporter.Core/Exporting/ExportContext.cs @@ -93,8 +93,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request) public IReadOnlyList GetUserRoles(Snowflake id) => TryGetMember(id) - ?.RoleIds - .Select(TryGetRole) + ?.RoleIds.Select(TryGetRole) .WhereNotNull() .OrderByDescending(r => r.Position) .ToArray() ?? []; diff --git a/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs b/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs index 2c3c8efb..7877194c 100644 --- a/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs +++ b/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs @@ -22,12 +22,11 @@ internal class ContainsMessageFilter(string text) : MessageFilter public override bool IsMatch(Message message) => IsMatch(message.Content) - || message.Embeds.Any( - e => - IsMatch(e.Title) - || IsMatch(e.Author?.Name) - || IsMatch(e.Description) - || IsMatch(e.Footer?.Text) - || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value)) + || message.Embeds.Any(e => + IsMatch(e.Title) + || IsMatch(e.Author?.Name) + || IsMatch(e.Description) + || IsMatch(e.Footer?.Text) + || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value)) ); } diff --git a/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs b/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs index ae98b14d..5ec9d0af 100644 --- a/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs +++ b/DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs @@ -7,11 +7,10 @@ namespace DiscordChatExporter.Core.Exporting.Filtering; internal class MentionsMessageFilter(string value) : MessageFilter { public override bool IsMatch(Message message) => - message.MentionedUsers.Any( - user => - string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase) - || string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase) - || string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase) - || string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase) + message.MentionedUsers.Any(user => + string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase) + || string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase) + || string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase) + || string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase) ); } diff --git a/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs b/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs index 8aaac425..20156944 100644 --- a/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs +++ b/DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs @@ -30,8 +30,8 @@ internal static class FilterGrammar .OneOf(QuotedString, UnquotedString) .Named("text string"); - private static readonly TextParser ContainsFilter = String.Select( - v => (MessageFilter)new ContainsMessageFilter(v) + private static readonly TextParser ContainsFilter = String.Select(v => + (MessageFilter)new ContainsMessageFilter(v) ); private static readonly TextParser FromFilter = Span.EqualToIgnoreCase("from:") diff --git a/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs b/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs index eb7bb576..d88faaa7 100644 --- a/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs +++ b/DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs @@ -7,10 +7,9 @@ namespace DiscordChatExporter.Core.Exporting.Filtering; internal class ReactionMessageFilter(string value) : MessageFilter { public override bool IsMatch(Message message) => - message.Reactions.Any( - r => - string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase) - || string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase) - || string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase) + message.Reactions.Any(r => + string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase) + || string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase) + || string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase) ); } diff --git a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs index 29435db4..735aade9 100644 --- a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs @@ -155,7 +155,9 @@ internal partial class HtmlMarkdownVisitor( buffer.Append( // lang=html $""" - {HtmlEncode(inlineCodeBlock.Code)} + {HtmlEncode( + inlineCodeBlock.Code + )} """ ); @@ -174,7 +176,9 @@ internal partial class HtmlMarkdownVisitor( buffer.Append( // lang=html $""" - {HtmlEncode(multiLineCodeBlock.Code)} + {HtmlEncode( + multiLineCodeBlock.Code + )} """ ); @@ -267,7 +271,9 @@ internal partial class HtmlMarkdownVisitor( buffer.Append( // lang=html $""" - @{HtmlEncode(displayName)} + @{HtmlEncode( + displayName + )} """ ); } @@ -292,8 +298,12 @@ internal partial class HtmlMarkdownVisitor( var style = color is not null ? $""" - color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1); - """ + color: rgb({color.Value.R}, {color.Value.G}, {color + .Value + .B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color + .Value + .B}, 0.1); + """ : null; buffer.Append( @@ -321,7 +331,9 @@ internal partial class HtmlMarkdownVisitor( buffer.Append( // lang=html $""" - {HtmlEncode(formatted)} + {HtmlEncode(formatted)} """ ); @@ -344,10 +356,8 @@ internal partial class HtmlMarkdownVisitor var isJumbo = isJumboAllowed - && nodes.All( - n => - n is EmojiNode - || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text) + && nodes.All(n => + n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text) ); var buffer = new StringBuilder(); diff --git a/DiscordChatExporter.Core/Utils/Http.cs b/DiscordChatExporter.Core/Utils/Http.cs index f08984f8..b70852ce 100644 --- a/DiscordChatExporter.Core/Utils/Http.cs +++ b/DiscordChatExporter.Core/Utils/Http.cs @@ -25,11 +25,10 @@ public static class Http private static bool IsRetryableException(Exception exception) => exception .GetSelfAndChildren() - .Any( - ex => - ex is TimeoutException or SocketException or AuthenticationException - || ex is HttpRequestException hrex - && IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK) + .Any(ex => + ex is TimeoutException or SocketException or AuthenticationException + || ex is HttpRequestException hrex + && IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK) ); public static ResiliencePipeline ResiliencePipeline { get; } = diff --git a/DiscordChatExporter.Gui/App.axaml b/DiscordChatExporter.Gui/App.axaml new file mode 100644 index 00000000..52f0d270 --- /dev/null +++ b/DiscordChatExporter.Gui/App.axaml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DiscordChatExporter.Gui/App.axaml.cs b/DiscordChatExporter.Gui/App.axaml.cs new file mode 100644 index 00000000..5032d8db --- /dev/null +++ b/DiscordChatExporter.Gui/App.axaml.cs @@ -0,0 +1,110 @@ +using System; +using System.Net; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Platform; +using DiscordChatExporter.Gui.Framework; +using DiscordChatExporter.Gui.Services; +using DiscordChatExporter.Gui.ViewModels; +using DiscordChatExporter.Gui.ViewModels.Components; +using DiscordChatExporter.Gui.ViewModels.Dialogs; +using DiscordChatExporter.Gui.Views; +using Material.Styles.Themes; +using Microsoft.Extensions.DependencyInjection; + +namespace DiscordChatExporter.Gui; + +public partial class App : Application, IDisposable +{ + private readonly ServiceProvider _services; + private readonly MainViewModel _mainViewModel; + + public App() + { + var services = new ServiceCollection(); + + // Framework + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Services + services.AddSingleton(); + services.AddSingleton(); + + // View models + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + _services = services.BuildServiceProvider(true); + _mainViewModel = _services.GetRequiredService().CreateMainViewModel(); + } + + public override void Initialize() + { + // Increase maximum concurrent connections + ServicePointManager.DefaultConnectionLimit = 20; + + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainView { DataContext = _mainViewModel }; + + base.OnFrameworkInitializationCompleted(); + + // Set custom theme colors + SetDefaultTheme(); + } + + public void Dispose() => _services.Dispose(); +} + +public partial class App +{ + public static void SetLightTheme() + { + if (Current is null) + return; + + Current.LocateMaterialTheme().CurrentTheme = Theme.Create( + Theme.Light, + Color.Parse("#343838"), + Color.Parse("#F9A825") + ); + } + + public static void SetDarkTheme() + { + if (Current is null) + return; + + Current.LocateMaterialTheme().CurrentTheme = Theme.Create( + Theme.Dark, + Color.Parse("#E8E8E8"), + Color.Parse("#F9A825") + ); + } + + public static void SetDefaultTheme() + { + if (Current is null) + return; + + var isDarkModeEnabledByDefault = + Current.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark; + + if (isDarkModeEnabledByDefault) + SetDarkTheme(); + else + SetLightTheme(); + } +} diff --git a/DiscordChatExporter.Gui/App.xaml b/DiscordChatExporter.Gui/App.xaml deleted file mode 100644 index e90a123d..00000000 --- a/DiscordChatExporter.Gui/App.xaml +++ /dev/null @@ -1,543 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Gui/App.xaml.cs b/DiscordChatExporter.Gui/App.xaml.cs deleted file mode 100644 index 35c890cf..00000000 --- a/DiscordChatExporter.Gui/App.xaml.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Reflection; -using DiscordChatExporter.Gui.Utils; -using MaterialDesignThemes.Wpf; - -namespace DiscordChatExporter.Gui; - -public partial class App -{ - private static Assembly Assembly { get; } = typeof(App).Assembly; - - public static string Name { get; } = Assembly.GetName().Name!; - - public static Version Version { get; } = Assembly.GetName().Version!; - - public static string VersionString { get; } = Version.ToString(3); - - public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter"; - - public static string LatestReleaseUrl { get; } = ProjectUrl + "/releases/latest"; - - public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs"; -} - -public partial class App -{ - private static Theme LightTheme { get; } = - Theme.Create( - new MaterialDesignLightTheme(), - MediaColor.FromHex("#343838"), - MediaColor.FromHex("#F9A825") - ); - - private static Theme DarkTheme { get; } = - Theme.Create( - new MaterialDesignDarkTheme(), - MediaColor.FromHex("#E8E8E8"), - MediaColor.FromHex("#F9A825") - ); - - public static void SetLightTheme() - { - var paletteHelper = new PaletteHelper(); - paletteHelper.SetTheme(LightTheme); - } - - public static void SetDarkTheme() - { - var paletteHelper = new PaletteHelper(); - paletteHelper.SetTheme(DarkTheme); - } -} diff --git a/DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs b/DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs deleted file mode 100644 index 66071f53..00000000 --- a/DiscordChatExporter.Gui/Behaviors/ChannelMultiSelectionListBoxBehavior.cs +++ /dev/null @@ -1,5 +0,0 @@ -using DiscordChatExporter.Core.Discord.Data; - -namespace DiscordChatExporter.Gui.Behaviors; - -public class ChannelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior; diff --git a/DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs b/DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs deleted file mode 100644 index 8e8242ec..00000000 --- a/DiscordChatExporter.Gui/Behaviors/MultiSelectionListBoxBehavior.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Collections; -using System.Collections.Specialized; -using System.Linq; -using System.Windows; -using System.Windows.Controls; -using Microsoft.Xaml.Behaviors; - -namespace DiscordChatExporter.Gui.Behaviors; - -public class MultiSelectionListBoxBehavior : Behavior -{ - public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register( - nameof(SelectedItems), - typeof(IList), - typeof(MultiSelectionListBoxBehavior), - new FrameworkPropertyMetadata( - null, - FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, - OnSelectedItemsChanged - ) - ); - - private static void OnSelectedItemsChanged( - DependencyObject sender, - DependencyPropertyChangedEventArgs args - ) - { - var behavior = (MultiSelectionListBoxBehavior)sender; - if (behavior._modelHandled) - return; - - if (behavior.AssociatedObject is null) - return; - - behavior._modelHandled = true; - behavior.SelectItems(); - behavior._modelHandled = false; - } - - private bool _viewHandled; - private bool _modelHandled; - - public IList? SelectedItems - { - get => (IList?)GetValue(SelectedItemsProperty); - set => SetValue(SelectedItemsProperty, value); - } - - // Propagate selected items from the model to the view - private void SelectItems() - { - _viewHandled = true; - - AssociatedObject.SelectedItems.Clear(); - if (SelectedItems is not null) - { - foreach (var item in SelectedItems) - AssociatedObject.SelectedItems.Add(item); - } - - _viewHandled = false; - } - - // Propagate selected items from the view to the model - private void OnListBoxSelectionChanged(object? sender, SelectionChangedEventArgs args) - { - if (_viewHandled) - return; - if (AssociatedObject.Items.SourceCollection is null) - return; - - SelectedItems = AssociatedObject.SelectedItems.Cast().ToArray(); - } - - private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args) - { - if (_viewHandled) - return; - if (AssociatedObject.Items.SourceCollection is null) - return; - SelectItems(); - } - - protected override void OnAttached() - { - base.OnAttached(); - - AssociatedObject.SelectionChanged += OnListBoxSelectionChanged; - ((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged += - OnListBoxItemsChanged; - } - - protected override void OnDetaching() - { - base.OnDetaching(); - - if (AssociatedObject is not null) - { - AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged; - ((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged -= - OnListBoxItemsChanged; - } - } -} diff --git a/DiscordChatExporter.Gui/Bootstrapper.cs b/DiscordChatExporter.Gui/Bootstrapper.cs deleted file mode 100644 index 1be1e8b1..00000000 --- a/DiscordChatExporter.Gui/Bootstrapper.cs +++ /dev/null @@ -1,45 +0,0 @@ -using DiscordChatExporter.Gui.Services; -using DiscordChatExporter.Gui.ViewModels; -using DiscordChatExporter.Gui.ViewModels.Framework; -using Stylet; -using StyletIoC; -#if !DEBUG -using System.Windows; -using System.Windows.Threading; -#endif - -namespace DiscordChatExporter.Gui; - -public class Bootstrapper : Bootstrapper -{ - protected override void OnStart() - { - base.OnStart(); - - // Set the default theme. - // Preferred theme will be set later, once the settings are loaded. - App.SetLightTheme(); - } - - protected override void ConfigureIoC(IStyletIoCBuilder builder) - { - base.ConfigureIoC(builder); - - builder.Bind().ToSelf().InSingletonScope(); - builder.Bind().ToAbstractFactory(); - } - -#if !DEBUG - protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args) - { - base.OnUnhandledException(args); - - MessageBox.Show( - args.Exception.ToString(), - "Error occured", - MessageBoxButton.OK, - MessageBoxImage.Error - ); - } -#endif -} diff --git a/DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs b/DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs deleted file mode 100644 index 37c91ba7..00000000 --- a/DiscordChatExporter.Gui/Converters/ChannelToGroupKeyConverter.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using DiscordChatExporter.Core.Discord.Data; - -namespace DiscordChatExporter.Gui.Converters; - -[ValueConversion(typeof(Channel), typeof(string))] -public class ChannelToGroupKeyConverter : IValueConverter -{ - public static ChannelToGroupKeyConverter Instance { get; } = new(); - - public object? Convert( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => - value switch - { - Channel { IsThread: true, Parent: not null } channel - => $"Threads in #{channel.Parent.Name}", - - Channel channel => channel.Parent?.Name ?? "???", - - _ => null - }; - - public object ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => throw new NotSupportedException(); -} diff --git a/DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs b/DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs new file mode 100644 index 00000000..d0922882 --- /dev/null +++ b/DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using DiscordChatExporter.Core.Discord.Data; + +namespace DiscordChatExporter.Gui.Converters; + +public class ChannelToHierarchicalNameStringConverter : IValueConverter +{ + public static ChannelToHierarchicalNameStringConverter Instance { get; } = new(); + + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) => value is Channel channel ? channel.GetHierarchicalName() : null; + + public object ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) => throw new NotSupportedException(); +} diff --git a/DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs b/DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs deleted file mode 100644 index 4511336e..00000000 --- a/DiscordChatExporter.Gui/Converters/DateTimeOffsetToDateTimeConverter.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace DiscordChatExporter.Gui.Converters; - -[ValueConversion(typeof(DateTimeOffset?), typeof(DateTime?))] -public class DateTimeOffsetToDateTimeConverter : IValueConverter -{ - public static DateTimeOffsetToDateTimeConverter Instance { get; } = new(); - - public object? Convert( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => - value is DateTimeOffset dateTimeOffsetValue - ? dateTimeOffsetValue.DateTime - : default(DateTime?); - - public object? ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => - value is DateTime dateTimeValue - ? new DateTimeOffset(dateTimeValue) - : default(DateTimeOffset?); -} diff --git a/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs b/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs index fe8a1322..1782c895 100644 --- a/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs +++ b/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs @@ -1,11 +1,10 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; using DiscordChatExporter.Core.Exporting; namespace DiscordChatExporter.Gui.Converters; -[ValueConversion(typeof(ExportFormat), typeof(string))] public class ExportFormatToStringConverter : IValueConverter { public static ExportFormatToStringConverter Instance { get; } = new(); @@ -15,7 +14,7 @@ public class ExportFormatToStringConverter : IValueConverter Type targetType, object? parameter, CultureInfo culture - ) => value is ExportFormat exportFormatValue ? exportFormatValue.GetDisplayName() : default; + ) => value is ExportFormat format ? format.GetDisplayName() : default; public object ConvertBack( object? value, diff --git a/DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs b/DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs deleted file mode 100644 index d6a8ad04..00000000 --- a/DiscordChatExporter.Gui/Converters/InverseBoolConverter.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace DiscordChatExporter.Gui.Converters; - -[ValueConversion(typeof(bool), typeof(bool))] -public class InverseBoolConverter : IValueConverter -{ - public static InverseBoolConverter Instance { get; } = new(); - - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => - value is false; - - public object ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => value is false; -} diff --git a/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs b/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs similarity index 71% rename from DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs rename to DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs index f7cff19c..5ec65d9d 100644 --- a/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs +++ b/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs @@ -1,13 +1,12 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; namespace DiscordChatExporter.Gui.Converters; -[ValueConversion(typeof(string), typeof(string))] -public class LocaleToDisplayNameConverter : IValueConverter +public class LocaleToDisplayNameStringConverter : IValueConverter { - public static LocaleToDisplayNameConverter Instance { get; } = new(); + public static LocaleToDisplayNameStringConverter Instance { get; } = new(); public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value is string locale && !string.IsNullOrWhiteSpace(locale) diff --git a/DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs b/DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs similarity index 59% rename from DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs rename to DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs index b4b0f015..11479336 100644 --- a/DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs +++ b/DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs @@ -1,21 +1,20 @@ using System; using System.Globalization; -using System.Windows.Data; +using Avalonia.Data.Converters; using DiscordChatExporter.Core.Discord; namespace DiscordChatExporter.Gui.Converters; -[ValueConversion(typeof(Snowflake?), typeof(DateTimeOffset?))] -public class SnowflakeToDateTimeOffsetConverter : IValueConverter +public class SnowflakeToTimestampStringConverter : IValueConverter { - public static SnowflakeToDateTimeOffsetConverter Instance { get; } = new(); + public static SnowflakeToTimestampStringConverter Instance { get; } = new(); public object? Convert( object? value, Type targetType, object? parameter, CultureInfo culture - ) => value is Snowflake snowflake ? snowflake.ToDate() : null; + ) => value is Snowflake snowflake ? snowflake.ToDate().ToString("g", culture) : null; public object ConvertBack( object? value, diff --git a/DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs b/DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs deleted file mode 100644 index 658d7a5f..00000000 --- a/DiscordChatExporter.Gui/Converters/TimeSpanToDateTimeConverter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace DiscordChatExporter.Gui.Converters; - -[ValueConversion(typeof(TimeSpan?), typeof(DateTime?))] -public class TimeSpanToDateTimeConverter : IValueConverter -{ - public static TimeSpanToDateTimeConverter Instance { get; } = new(); - - public object? Convert( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => value is TimeSpan timeSpanValue ? DateTime.Today.Add(timeSpanValue) : default(DateTime?); - - public object? ConvertBack( - object? value, - Type targetType, - object? parameter, - CultureInfo culture - ) => value is DateTime dateTimeValue ? dateTimeValue.TimeOfDay : default(TimeSpan?); -} diff --git a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj index fad90a59..8d32b29b 100644 --- a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj +++ b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj @@ -2,28 +2,30 @@ WinExe - $(TargetFramework)-windows DiscordChatExporter - true - ../favicon.ico + ..\favicon.ico - + + + + + - + + - + + - - - - - - + + + + diff --git a/DiscordChatExporter.Gui/FodyWeavers.xml b/DiscordChatExporter.Gui/FodyWeavers.xml deleted file mode 100644 index 4e68ed1a..00000000 --- a/DiscordChatExporter.Gui/FodyWeavers.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Gui/FodyWeavers.xsd b/DiscordChatExporter.Gui/FodyWeavers.xsd deleted file mode 100644 index 69dbe488..00000000 --- a/DiscordChatExporter.Gui/FodyWeavers.xsd +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - Used to control if the On_PropertyName_Changed feature is enabled. - - - - - Used to control if the Dependent properties feature is enabled. - - - - - Used to control if the IsChanged property feature is enabled. - - - - - Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form. - - - - - Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project. - - - - - Used to control if equality checks should use the Equals method resolved from the base class. - - - - - Used to control if equality checks should use the static Equals method resolved from the base class. - - - - - Used to turn off build warnings from this weaver. - - - - - Used to turn off build warnings about mismatched On_PropertyName_Changed methods. - - - - - - - - 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. - - - - - A comma-separated list of error codes that can be safely ignored in assembly verification. - - - - - 'false' to turn off automatic generation of the XML Schema file. - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Framework/DialogManager.cs b/DiscordChatExporter.Gui/Framework/DialogManager.cs new file mode 100644 index 00000000..6ec2f322 --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/DialogManager.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AsyncKeyedLock; +using Avalonia; +using Avalonia.Platform.Storage; +using DialogHostAvalonia; +using DiscordChatExporter.Gui.Utils.Extensions; + +namespace DiscordChatExporter.Gui.Framework; + +public class DialogManager : IDisposable +{ + private readonly AsyncNonKeyedLocker _dialogLock = new(); + + public async Task ShowDialogAsync(DialogViewModelBase dialog) + { + using (await _dialogLock.LockAsync()) + { + await DialogHost.Show( + dialog, + // It's fine to await in a void method here because it's an event handler + // ReSharper disable once AsyncVoidLambda + async (object _, DialogOpenedEventArgs args) => + { + await dialog.WaitForCloseAsync(); + + try + { + args.Session.Close(); + } + catch (InvalidOperationException) + { + // Dialog host is already processing a close operation + } + } + ); + + return dialog.DialogResult; + } + } + + public async Task PromptSaveFilePathAsync( + IReadOnlyList? fileTypes = null, + string defaultFilePath = "" + ) + { + var topLevel = + Application.Current?.ApplicationLifetime?.TryGetTopLevel() + ?? throw new ApplicationException("Could not find the top-level visual element."); + + var file = await topLevel.StorageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions + { + FileTypeChoices = fileTypes, + SuggestedFileName = defaultFilePath, + DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.') + } + ); + + return file?.Path.LocalPath; + } + + public async Task PromptDirectoryPathAsync(string defaultDirPath = "") + { + var topLevel = + Application.Current?.ApplicationLifetime?.TryGetTopLevel() + ?? throw new ApplicationException("Could not find the top-level visual element."); + + var startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync( + defaultDirPath + ); + + var folderPickResult = await topLevel.StorageProvider.OpenFolderPickerAsync( + new FolderPickerOpenOptions + { + AllowMultiple = false, + SuggestedStartLocation = startLocation + } + ); + + return folderPickResult.FirstOrDefault()?.Path.LocalPath; + } + + public void Dispose() => _dialogLock.Dispose(); +} diff --git a/DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs b/DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs new file mode 100644 index 00000000..94f28bac --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace DiscordChatExporter.Gui.Framework; + +public abstract partial class DialogViewModelBase : ViewModelBase +{ + private readonly TaskCompletionSource _closeTcs = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + [ObservableProperty] + private T? _dialogResult; + + [RelayCommand] + protected void Close(T dialogResult) + { + DialogResult = dialogResult; + _closeTcs.TrySetResult(dialogResult); + } + + public async Task WaitForCloseAsync() => await _closeTcs.Task; +} + +public abstract class DialogViewModelBase : DialogViewModelBase; diff --git a/DiscordChatExporter.Gui/Framework/SnackbarManager.cs b/DiscordChatExporter.Gui/Framework/SnackbarManager.cs new file mode 100644 index 00000000..a4af9f5d --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/SnackbarManager.cs @@ -0,0 +1,34 @@ +using System; +using Avalonia.Threading; +using Material.Styles.Controls; +using Material.Styles.Models; + +namespace DiscordChatExporter.Gui.Framework; + +public class SnackbarManager +{ + private readonly TimeSpan _defaultDuration = TimeSpan.FromSeconds(5); + + public void Notify(string message, TimeSpan? duration = null) => + SnackbarHost.Post( + new SnackbarModel(message, duration ?? _defaultDuration), + null, + DispatcherPriority.Normal + ); + + public void Notify( + string message, + string actionText, + Action actionHandler, + TimeSpan? duration = null + ) => + SnackbarHost.Post( + new SnackbarModel( + message, + duration ?? _defaultDuration, + new SnackbarButtonModel { Text = actionText, Action = actionHandler } + ), + null, + DispatcherPriority.Normal + ); +} diff --git a/DiscordChatExporter.Gui/Framework/UserControl.cs b/DiscordChatExporter.Gui/Framework/UserControl.cs new file mode 100644 index 00000000..fda1b3ea --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/UserControl.cs @@ -0,0 +1,18 @@ +using System; +using Avalonia.Controls; + +namespace DiscordChatExporter.Gui.Framework; + +public class UserControl : UserControl +{ + public new TDataContext DataContext + { + get => + base.DataContext is TDataContext dataContext + ? dataContext + : throw new InvalidCastException( + $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'." + ); + set => base.DataContext = value; + } +} diff --git a/DiscordChatExporter.Gui/Framework/ViewManager.cs b/DiscordChatExporter.Gui/Framework/ViewManager.cs new file mode 100644 index 00000000..f335e449 --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/ViewManager.cs @@ -0,0 +1,37 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; + +namespace DiscordChatExporter.Gui.Framework; + +public partial class ViewManager +{ + public Control? TryBindView(ViewModelBase viewModel) + { + var name = viewModel + .GetType() + .FullName?.Replace("ViewModel", "View", StringComparison.Ordinal); + + if (string.IsNullOrWhiteSpace(name)) + return null; + + var type = Type.GetType(name); + if (type is null) + return null; + + if (Activator.CreateInstance(type) is not Control view) + return null; + + view.DataContext ??= viewModel; + + return view; + } +} + +public partial class ViewManager : IDataTemplate +{ + bool IDataTemplate.Match(object? data) => data is ViewModelBase; + + Control? ITemplate.Build(object? data) => + data is ViewModelBase viewModel ? TryBindView(viewModel) : null; +} diff --git a/DiscordChatExporter.Gui/Framework/ViewModelBase.cs b/DiscordChatExporter.Gui/Framework/ViewModelBase.cs new file mode 100644 index 00000000..2f8a0076 --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/ViewModelBase.cs @@ -0,0 +1,19 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace DiscordChatExporter.Gui.Framework; + +public abstract class ViewModelBase : ObservableObject, IDisposable +{ + ~ViewModelBase() => Dispose(false); + + protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Empty); + + protected virtual void Dispose(bool disposing) { } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/DiscordChatExporter.Gui/Framework/ViewModelManager.cs b/DiscordChatExporter.Gui/Framework/ViewModelManager.cs new file mode 100644 index 00000000..4102280b --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/ViewModelManager.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Gui.ViewModels; +using DiscordChatExporter.Gui.ViewModels.Components; +using DiscordChatExporter.Gui.ViewModels.Dialogs; +using Microsoft.Extensions.DependencyInjection; + +namespace DiscordChatExporter.Gui.Framework; + +public class ViewModelManager(IServiceProvider services) +{ + public MainViewModel CreateMainViewModel() => services.GetRequiredService(); + + public DashboardViewModel CreateDashboardViewModel() => + services.GetRequiredService(); + + public ExportSetupViewModel CreateExportSetupViewModel( + Guild guild, + IReadOnlyList channels + ) + { + var viewModel = services.GetRequiredService(); + + viewModel.Guild = guild; + viewModel.Channels = channels; + + return viewModel; + } + + public MessageBoxViewModel CreateMessageBoxViewModel( + string title, + string message, + string? okButtonText, + string? cancelButtonText + ) + { + var viewModel = services.GetRequiredService(); + + viewModel.Title = title; + viewModel.Message = message; + viewModel.DefaultButtonText = okButtonText; + viewModel.CancelButtonText = cancelButtonText; + + return viewModel; + } + + public MessageBoxViewModel CreateMessageBoxViewModel(string title, string message) => + CreateMessageBoxViewModel(title, message, "CLOSE", null); + + public SettingsViewModel CreateSettingsViewModel() => + services.GetRequiredService(); +} diff --git a/DiscordChatExporter.Gui/Framework/Window.cs b/DiscordChatExporter.Gui/Framework/Window.cs new file mode 100644 index 00000000..a5ff98ec --- /dev/null +++ b/DiscordChatExporter.Gui/Framework/Window.cs @@ -0,0 +1,18 @@ +using System; +using Avalonia.Controls; + +namespace DiscordChatExporter.Gui.Framework; + +public class Window : Window +{ + public new TDataContext DataContext + { + get => + base.DataContext is TDataContext dataContext + ? dataContext + : throw new InvalidCastException( + $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'." + ); + set => base.DataContext = value; + } +} diff --git a/DiscordChatExporter.Gui/Program.cs b/DiscordChatExporter.Gui/Program.cs new file mode 100644 index 00000000..a8a494ad --- /dev/null +++ b/DiscordChatExporter.Gui/Program.cs @@ -0,0 +1,51 @@ +using System; +using System.Reflection; +using Avalonia; +using DiscordChatExporter.Gui.Utils; + +namespace DiscordChatExporter.Gui; + +public static class Program +{ + private static Assembly Assembly { get; } = typeof(App).Assembly; + + public static string Name { get; } = Assembly.GetName().Name!; + + public static Version Version { get; } = Assembly.GetName().Version!; + + public static string VersionString { get; } = Version.ToString(3); + + public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter"; + + public static string LatestReleaseUrl { get; } = ProjectUrl + "/releases/latest"; + + public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs"; + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure().UsePlatformDetect().LogToTrace(); + + [STAThread] + public static int Main(string[] args) + { + // Build and run the app + var builder = BuildAvaloniaApp(); + + try + { + return builder.StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) + { + if (OperatingSystem.IsWindows()) + _ = NativeMethods.Windows.MessageBox(0, ex.ToString(), "Fatal Error", 0x10); + + throw; + } + finally + { + // Clean up after application shutdown + if (builder.Instance is IDisposable disposableApp) + disposableApp.Dispose(); + } + } +} diff --git a/DiscordChatExporter.Gui/Services/SettingsService.cs b/DiscordChatExporter.Gui/Services/SettingsService.cs index 58310ef1..4294c4b6 100644 --- a/DiscordChatExporter.Gui/Services/SettingsService.cs +++ b/DiscordChatExporter.Gui/Services/SettingsService.cs @@ -1,48 +1,80 @@ using System; using System.IO; +using Avalonia; +using Avalonia.Platform; using Cogwheel; +using CommunityToolkit.Mvvm.ComponentModel; using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Gui.Models; using Microsoft.Win32; namespace DiscordChatExporter.Gui.Services; +[INotifyPropertyChanged] public partial class SettingsService() : SettingsBase(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat")) { - public bool IsUkraineSupportMessageEnabled { get; set; } = true; + [ObservableProperty] + private bool _isUkraineSupportMessageEnabled = true; - public bool IsAutoUpdateEnabled { get; set; } = true; + [ObservableProperty] + private bool _isAutoUpdateEnabled = true; - public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault(); + [ObservableProperty] + private bool _isDarkModeEnabled; - public bool IsTokenPersisted { get; set; } = true; + [ObservableProperty] + private bool _isTokenPersisted = true; - public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None; + [ObservableProperty] + private ThreadInclusionMode _threadInclusionMode; - public string? Locale { get; set; } + [ObservableProperty] + private string? _locale; - public bool IsUtcNormalizationEnabled { get; set; } + [ObservableProperty] + private bool _isUtcNormalizationEnabled; - public int ParallelLimit { get; set; } = 1; + [ObservableProperty] + private int _parallelLimit = 1; - public Version? LastAppVersion { get; set; } + [ObservableProperty] + private Version? _lastAppVersion; - public string? LastToken { get; set; } + [ObservableProperty] + private string? _lastToken; - public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark; + [ObservableProperty] + private ExportFormat _lastExportFormat = ExportFormat.HtmlDark; - public string? LastPartitionLimitValue { get; set; } + [ObservableProperty] + private string? _lastPartitionLimitValue; - public string? LastMessageFilterValue { get; set; } + [ObservableProperty] + private string? _lastMessageFilterValue; - public bool LastShouldFormatMarkdown { get; set; } = true; + [ObservableProperty] + private bool _lastShouldFormatMarkdown = true; - public bool LastShouldDownloadAssets { get; set; } + [ObservableProperty] + private bool _lastShouldDownloadAssets; - public bool LastShouldReuseAssets { get; set; } + [ObservableProperty] + private bool _lastShouldReuseAssets; - public string? LastAssetsDirPath { get; set; } + [ObservableProperty] + private string? _lastAssetsDirPath; + + public override void Reset() + { + base.Reset(); + + // Reset the dark mode setting separately because its default value is evaluated dynamically + // and cannot be set by the field initializer. + IsDarkModeEnabled = + Application.Current?.PlatformSettings?.GetColorValues().ThemeVariant + == PlatformThemeVariant.Dark; + } public override void Save() { @@ -56,24 +88,3 @@ public partial class SettingsService() LastToken = lastToken; } } - -public partial class SettingsService -{ - private static bool IsDarkModeEnabledByDefault() - { - try - { - return Registry - .CurrentUser.OpenSubKey( - "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", - false - ) - ?.GetValue("AppsUseLightTheme") - is 0; - } - catch - { - return false; - } - } -} diff --git a/DiscordChatExporter.Gui/Services/UpdateService.cs b/DiscordChatExporter.Gui/Services/UpdateService.cs index 738ba216..cefb053a 100644 --- a/DiscordChatExporter.Gui/Services/UpdateService.cs +++ b/DiscordChatExporter.Gui/Services/UpdateService.cs @@ -51,6 +51,10 @@ public class UpdateService(SettingsService settingsService) : IDisposable if (!settingsService.IsAutoUpdateEnabled) return; + // Onova only works on Windows currently + if (!OperatingSystem.IsWindows()) + return; + if (_updateVersion is null || !_updatePrepared || _updaterLaunched) return; diff --git a/DiscordChatExporter.Gui/Utils/Disposable.cs b/DiscordChatExporter.Gui/Utils/Disposable.cs new file mode 100644 index 00000000..e7bdfb63 --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/Disposable.cs @@ -0,0 +1,10 @@ +using System; + +namespace DiscordChatExporter.Gui.Utils; + +internal class Disposable(Action dispose) : IDisposable +{ + public static IDisposable Create(Action dispose) => new Disposable(dispose); + + public void Dispose() => dispose(); +} diff --git a/DiscordChatExporter.Gui/Utils/DisposableCollector.cs b/DiscordChatExporter.Gui/Utils/DisposableCollector.cs new file mode 100644 index 00000000..e96761b9 --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/DisposableCollector.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using DiscordChatExporter.Gui.Utils.Extensions; + +namespace DiscordChatExporter.Gui.Utils; + +internal class DisposableCollector : IDisposable +{ + private readonly object _lock = new(); + private readonly List _items = []; + + public void Add(IDisposable item) + { + lock (_lock) + { + _items.Add(item); + } + } + + public void Dispose() + { + lock (_lock) + { + _items.DisposeAll(); + _items.Clear(); + } + } +} diff --git a/DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs b/DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs new file mode 100644 index 00000000..15df033c --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs @@ -0,0 +1,34 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.VisualTree; + +namespace DiscordChatExporter.Gui.Utils.Extensions; + +internal static class AvaloniaExtensions +{ + public static Window? TryGetMainWindow(this IApplicationLifetime lifetime) => + lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime + ? desktopLifetime.MainWindow + : null; + + public static TopLevel? TryGetTopLevel(this IApplicationLifetime lifetime) => + lifetime.TryGetMainWindow() + ?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel; + + public static bool TryShutdown(this IApplicationLifetime lifetime, int exitCode = 0) + { + if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { + return desktopLifetime.TryShutdown(exitCode); + } + + if (lifetime is IControlledApplicationLifetime controlledLifetime) + { + controlledLifetime.Shutdown(exitCode); + return true; + } + + return false; + } +} diff --git a/DiscordChatExporter.Gui/Utils/Extensions/DisposableExtensions.cs b/DiscordChatExporter.Gui/Utils/Extensions/DisposableExtensions.cs new file mode 100644 index 00000000..de8651d0 --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/Extensions/DisposableExtensions.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace DiscordChatExporter.Gui.Utils.Extensions; + +internal static class DisposableExtensions +{ + public static void DisposeAll(this IEnumerable disposables) + { + var exceptions = default(List); + + foreach (var disposable in disposables) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + (exceptions ??= []).Add(ex); + } + } + + if (exceptions?.Any() == true) + throw new AggregateException(exceptions); + } +} diff --git a/DiscordChatExporter.Gui/Utils/Extensions/NotifyPropertyChangedExtensions.cs b/DiscordChatExporter.Gui/Utils/Extensions/NotifyPropertyChangedExtensions.cs new file mode 100644 index 00000000..2f9744fc --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/Extensions/NotifyPropertyChangedExtensions.cs @@ -0,0 +1,60 @@ +using System; +using System.ComponentModel; +using System.Linq.Expressions; +using System.Reflection; + +namespace DiscordChatExporter.Gui.Utils.Extensions; + +internal static class NotifyPropertyChangedExtensions +{ + public static IDisposable WatchProperty( + this TOwner owner, + Expression> propertyExpression, + Action callback, + bool watchInitialValue = true + ) + where TOwner : INotifyPropertyChanged + { + var memberExpression = + propertyExpression.Body as MemberExpression + // Property value might be boxed inside a conversion expression, if the types don't match + ?? (propertyExpression.Body as UnaryExpression)?.Operand as MemberExpression; + + if (memberExpression?.Member is not PropertyInfo property) + throw new ArgumentException("Provided expression must reference a property."); + + void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) + { + if ( + string.IsNullOrWhiteSpace(args.PropertyName) + || string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal) + ) + { + callback(); + } + } + + owner.PropertyChanged += OnPropertyChanged; + + if (watchInitialValue) + callback(); + + return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); + } + + public static IDisposable WatchAllProperties( + this TOwner owner, + Action callback, + bool watchInitialValues = true + ) + where TOwner : INotifyPropertyChanged + { + void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback(); + owner.PropertyChanged += OnPropertyChanged; + + if (watchInitialValues) + callback(); + + return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); + } +} diff --git a/DiscordChatExporter.Gui/Utils/Internationalization.cs b/DiscordChatExporter.Gui/Utils/Internationalization.cs index 7567c3d7..bcc58ae0 100644 --- a/DiscordChatExporter.Gui/Utils/Internationalization.cs +++ b/DiscordChatExporter.Gui/Utils/Internationalization.cs @@ -7,4 +7,6 @@ internal static class Internationalization public static bool Is24Hours => string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator) && string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator); + + public static string AvaloniaClockIdentifier => Is24Hours ? "24HourClock" : "12HourClock"; } diff --git a/DiscordChatExporter.Gui/Utils/MediaColor.cs b/DiscordChatExporter.Gui/Utils/MediaColor.cs deleted file mode 100644 index 07fb8acb..00000000 --- a/DiscordChatExporter.Gui/Utils/MediaColor.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Windows.Media; - -namespace DiscordChatExporter.Gui.Utils; - -internal static class MediaColor -{ - public static Color FromHex(string hex) => (Color)ColorConverter.ConvertFromString(hex); -} diff --git a/DiscordChatExporter.Gui/Utils/NativeMethods.cs b/DiscordChatExporter.Gui/Utils/NativeMethods.cs new file mode 100644 index 00000000..a52aa6e2 --- /dev/null +++ b/DiscordChatExporter.Gui/Utils/NativeMethods.cs @@ -0,0 +1,12 @@ +using System.Runtime.InteropServices; + +namespace DiscordChatExporter.Gui.Utils; + +internal static class NativeMethods +{ + public static class Windows + { + [DllImport("user32.dll", SetLastError = true)] + public static extern int MessageBox(nint hWnd, string text, string caption, uint type); + } +} diff --git a/DiscordChatExporter.Gui/Utils/ProcessEx.cs b/DiscordChatExporter.Gui/Utils/ProcessEx.cs index 100550bc..82ef9eba 100644 --- a/DiscordChatExporter.Gui/Utils/ProcessEx.cs +++ b/DiscordChatExporter.Gui/Utils/ProcessEx.cs @@ -6,10 +6,8 @@ internal static class ProcessEx { public static void StartShellExecute(string path) { - using var process = new Process - { - StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true } - }; + using var process = new Process(); + process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true }; process.Start(); } diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index cf075d3f..5ee722f6 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -1,104 +1,113 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Exceptions; using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Utils.Extensions; +using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Models; using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Utils; -using DiscordChatExporter.Gui.ViewModels.Dialogs; -using DiscordChatExporter.Gui.ViewModels.Framework; -using DiscordChatExporter.Gui.ViewModels.Messages; +using DiscordChatExporter.Gui.Utils.Extensions; using Gress; using Gress.Completable; -using Stylet; namespace DiscordChatExporter.Gui.ViewModels.Components; -public class DashboardViewModel : PropertyChangedBase +public partial class DashboardViewModel : ViewModelBase { - private readonly IViewModelFactory _viewModelFactory; - private readonly IEventAggregator _eventAggregator; + private readonly ViewModelManager _viewModelManager; + private readonly SnackbarManager _snackbarManager; private readonly DialogManager _dialogManager; private readonly SettingsService _settingsService; + private readonly DisposableCollector _eventRoot = new(); private readonly AutoResetProgressMuxer _progressMuxer; private DiscordClient? _discord; - public bool IsBusy { get; private set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsProgressIndeterminate))] + [NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))] + [NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))] + [NotifyCanExecuteChangedFor(nameof(ExportCommand))] + private bool _isBusy; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))] + private string? _token; + + [ObservableProperty] + private IReadOnlyList? _availableGuilds; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))] + [NotifyCanExecuteChangedFor(nameof(ExportCommand))] + private Guild? _selectedGuild; + + [ObservableProperty] + private IReadOnlyList? _availableChannels; + + public DashboardViewModel( + ViewModelManager viewModelManager, + DialogManager dialogManager, + SnackbarManager snackbarManager, + SettingsService settingsService + ) + { + _viewModelManager = viewModelManager; + _dialogManager = dialogManager; + _snackbarManager = snackbarManager; + _settingsService = settingsService; + + _progressMuxer = Progress.CreateMuxer().WithAutoReset(); + + _eventRoot.Add( + Progress.WatchProperty( + o => o.Current, + () => OnPropertyChanged(nameof(IsProgressIndeterminate)) + ) + ); + + _eventRoot.Add( + SelectedChannels.WatchProperty( + o => o.Count, + () => ExportCommand.NotifyCanExecuteChanged() + ) + ); + } public ProgressContainer Progress { get; } = new(); public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1; - public string? Token { get; set; } + public ObservableCollection SelectedChannels { get; } = []; - public IReadOnlyList? AvailableGuilds { get; private set; } - - public Guild? SelectedGuild { get; set; } - - public IReadOnlyList? AvailableChannels { get; private set; } - - public IReadOnlyList? SelectedChannels { get; set; } - - public DashboardViewModel( - IViewModelFactory viewModelFactory, - IEventAggregator eventAggregator, - DialogManager dialogManager, - SettingsService settingsService - ) - { - _viewModelFactory = viewModelFactory; - _eventAggregator = eventAggregator; - _dialogManager = dialogManager; - _settingsService = settingsService; - - _progressMuxer = Progress.CreateMuxer().WithAutoReset(); - - this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)); - - Progress.Bind( - o => o.Current, - (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate) - ); - - this.Bind( - o => o.SelectedGuild, - (_, _) => - { - // Reset channels when the selected guild changes, to avoid jitter - // due to the channels being asynchronously loaded. - AvailableChannels = null; - SelectedChannels = null; - - // Pull channels for the selected guild - // (ideally this should be called inside `PullGuilds()`, - // but Stylet doesn't support async commands) - PullChannels(); - } - ); - } - - public void OnViewLoaded() + [RelayCommand] + private void Initialize() { if (!string.IsNullOrWhiteSpace(_settingsService.LastToken)) Token = _settingsService.LastToken; } - public async void ShowSettings() => - await _dialogManager.ShowDialogAsync(_viewModelFactory.CreateSettingsViewModel()); + [RelayCommand] + private async Task ShowSettingsAsync() => + await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel()); - public void ShowHelp() => ProcessEx.StartShellExecute(App.DocumentationUrl); + [RelayCommand] + private void ShowHelp() => ProcessEx.StartShellExecute(Program.DocumentationUrl); - public bool CanPullGuilds => !IsBusy && !string.IsNullOrWhiteSpace(Token); + private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token); - public async void PullGuilds() + [RelayCommand(CanExecute = nameof(CanPullGuilds))] + private async Task PullGuildsAsync() { IsBusy = true; var progress = _progressMuxer.CreateInput(); @@ -112,7 +121,7 @@ public class DashboardViewModel : PropertyChangedBase AvailableGuilds = null; SelectedGuild = null; AvailableChannels = null; - SelectedChannels = null; + SelectedChannels.Clear(); _discord = new DiscordClient(token); _settingsService.LastToken = token; @@ -121,14 +130,16 @@ public class DashboardViewModel : PropertyChangedBase AvailableGuilds = guilds; SelectedGuild = guilds.FirstOrDefault(); + + await PullChannelsAsync(); } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { - _eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.'))); + _snackbarManager.Notify(ex.Message.TrimEnd('.')); } catch (Exception ex) { - var dialog = _viewModelFactory.CreateMessageBoxViewModel( + var dialog = _viewModelManager.CreateMessageBoxViewModel( "Error pulling guilds", ex.ToString() ); @@ -142,9 +153,10 @@ public class DashboardViewModel : PropertyChangedBase } } - public bool CanPullChannels => !IsBusy && _discord is not null && SelectedGuild is not null; + private bool CanPullChannels() => !IsBusy && _discord is not null && SelectedGuild is not null; - public async void PullChannels() + [RelayCommand(CanExecute = nameof(CanPullChannels))] + private async Task PullChannelsAsync() { IsBusy = true; var progress = _progressMuxer.CreateInput(); @@ -155,18 +167,13 @@ public class DashboardViewModel : PropertyChangedBase return; AvailableChannels = null; - SelectedChannels = null; + SelectedChannels.Clear(); var channels = new List(); // Regular channels await foreach (var channel in _discord.GetGuildChannelsAsync(SelectedGuild.Id)) - { - if (channel.IsCategory) - continue; - channels.Add(channel); - } // Threads if (_settingsService.ThreadInclusionMode != ThreadInclusionMode.None) @@ -182,16 +189,24 @@ public class DashboardViewModel : PropertyChangedBase } } - AvailableChannels = channels; - SelectedChannels = null; + // Build a hierarchy of channels + var channelTree = ChannelNode.BuildTree( + channels + .OrderByDescending(c => c.IsDirect ? c.LastMessageId : null) + .ThenBy(c => c.Position) + .ToArray() + ); + + AvailableChannels = channelTree; + SelectedChannels.Clear(); } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { - _eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.'))); + _snackbarManager.Notify(ex.Message.TrimEnd('.')); } catch (Exception ex) { - var dialog = _viewModelFactory.CreateMessageBoxViewModel( + var dialog = _viewModelManager.CreateMessageBoxViewModel( "Error pulling channels", ex.ToString() ); @@ -205,30 +220,24 @@ public class DashboardViewModel : PropertyChangedBase } } - public bool CanExport => - !IsBusy - && _discord is not null - && SelectedGuild is not null - && SelectedChannels?.Any() is true; + private bool CanExport() => + !IsBusy && _discord is not null && SelectedGuild is not null && SelectedChannels.Any(); - public async void Export() + [RelayCommand(CanExecute = nameof(CanExport))] + private async Task ExportAsync() { IsBusy = true; try { - if ( - _discord is null - || SelectedGuild is null - || SelectedChannels is null - || !SelectedChannels.Any() - ) + if (_discord is null || SelectedGuild is null || !SelectedChannels.Any()) return; - var dialog = _viewModelFactory.CreateExportSetupViewModel( + var dialog = _viewModelManager.CreateExportSetupViewModel( SelectedGuild, - SelectedChannels + SelectedChannels.Select(c => c.Channel).ToArray() ); + if (await _dialogManager.ShowDialogAsync(dialog) != true) return; @@ -276,7 +285,7 @@ public class DashboardViewModel : PropertyChangedBase } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { - _eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.'))); + _snackbarManager.Notify(ex.Message.TrimEnd('.')); } finally { @@ -288,16 +297,14 @@ public class DashboardViewModel : PropertyChangedBase // Notify of the overall completion if (successfulExportCount > 0) { - _eventAggregator.Publish( - new NotificationMessage( - $"Successfully exported {successfulExportCount} channel(s)" - ) + _snackbarManager.Notify( + $"Successfully exported {successfulExportCount} channel(s)" ); } } catch (Exception ex) { - var dialog = _viewModelFactory.CreateMessageBoxViewModel( + var dialog = _viewModelManager.CreateMessageBoxViewModel( "Error exporting channel(s)", ex.ToString() ); @@ -310,8 +317,20 @@ public class DashboardViewModel : PropertyChangedBase } } - public void OpenDiscord() => ProcessEx.StartShellExecute("https://discord.com/app"); + [RelayCommand] + private void OpenDiscord() => ProcessEx.StartShellExecute("https://discord.com/app"); - public void OpenDiscordDeveloperPortal() => + [RelayCommand] + private void OpenDiscordDeveloperPortal() => ProcessEx.StartShellExecute("https://discord.com/developers/applications"); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _eventRoot.Dispose(); + } + + base.Dispose(disposing); + } } diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs index 0522e8bf..5ea15b36 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs @@ -1,89 +1,111 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Utils.Extensions; +using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Services; -using DiscordChatExporter.Gui.ViewModels.Framework; namespace DiscordChatExporter.Gui.ViewModels.Dialogs; -public class ExportSetupViewModel : DialogScreen +public partial class ExportSetupViewModel( + DialogManager dialogManager, + SettingsService settingsService +) : DialogViewModelBase { - private readonly DialogManager _dialogManager; - private readonly SettingsService _settingsService; + [ObservableProperty] + private Guild? _guild; - public Guild? Guild { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSingleChannel))] + private IReadOnlyList? _channels; - public IReadOnlyList? Channels { get; set; } + [ObservableProperty] + private string? _outputPath; + + [ObservableProperty] + private ExportFormat _selectedFormat; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAfterDateSet))] + [NotifyPropertyChangedFor(nameof(After))] + private DateTimeOffset? _afterDate; + + [ObservableProperty] + private TimeSpan? _afterTime; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsBeforeDateSet))] + [NotifyPropertyChangedFor(nameof(Before))] + private DateTimeOffset? _beforeDate; + + [ObservableProperty] + private TimeSpan? _beforeTime; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PartitionLimit))] + private string? _partitionLimitValue; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(MessageFilter))] + private string? _messageFilterValue; + + [ObservableProperty] + private bool _shouldFormatMarkdown; + + [ObservableProperty] + private bool _shouldDownloadAssets; + + [ObservableProperty] + private bool _shouldReuseAssets; + + [ObservableProperty] + private string? _assetsDirPath; + + [ObservableProperty] + private bool _isAdvancedSectionDisplayed; public bool IsSingleChannel => Channels?.Count == 1; - public string? OutputPath { get; set; } - public IReadOnlyList AvailableFormats { get; } = Enum.GetValues(); - public ExportFormat SelectedFormat { get; set; } - - // This date/time abomination is required because we use separate controls to set these - - public DateTimeOffset? AfterDate { get; set; } - public bool IsAfterDateSet => AfterDate is not null; - public TimeSpan? AfterTime { get; set; } - public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero); - public DateTimeOffset? BeforeDate { get; set; } - public bool IsBeforeDateSet => BeforeDate is not null; - public TimeSpan? BeforeTime { get; set; } - public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero); - public string? PartitionLimitValue { get; set; } - public PartitionLimit PartitionLimit => !string.IsNullOrWhiteSpace(PartitionLimitValue) ? PartitionLimit.Parse(PartitionLimitValue) : PartitionLimit.Null; - public string? MessageFilterValue { get; set; } - public MessageFilter MessageFilter => !string.IsNullOrWhiteSpace(MessageFilterValue) ? MessageFilter.Parse(MessageFilterValue) : MessageFilter.Null; - public bool ShouldFormatMarkdown { get; set; } - - public bool ShouldDownloadAssets { get; set; } - - public bool ShouldReuseAssets { get; set; } - - public string? AssetsDirPath { get; set; } - - public bool IsAdvancedSectionDisplayed { get; set; } - - public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService) + [RelayCommand] + private void Initialize() { - _dialogManager = dialogManager; - _settingsService = settingsService; - // Persist preferences - SelectedFormat = _settingsService.LastExportFormat; - PartitionLimitValue = _settingsService.LastPartitionLimitValue; - MessageFilterValue = _settingsService.LastMessageFilterValue; - ShouldFormatMarkdown = _settingsService.LastShouldFormatMarkdown; - ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets; - ShouldReuseAssets = _settingsService.LastShouldReuseAssets; - AssetsDirPath = _settingsService.LastAssetsDirPath; + SelectedFormat = settingsService.LastExportFormat; + PartitionLimitValue = settingsService.LastPartitionLimitValue; + MessageFilterValue = settingsService.LastMessageFilterValue; + ShouldFormatMarkdown = settingsService.LastShouldFormatMarkdown; + ShouldDownloadAssets = settingsService.LastShouldDownloadAssets; + ShouldReuseAssets = settingsService.LastShouldReuseAssets; + AssetsDirPath = settingsService.LastAssetsDirPath; // Show the "advanced options" section by default if any // of the advanced options are set to non-default values. @@ -97,9 +119,8 @@ public class ExportSetupViewModel : DialogScreen || !string.IsNullOrWhiteSpace(AssetsDirPath); } - public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed; - - public void ShowOutputPathPrompt() + [RelayCommand] + private async Task ShowOutputPathPromptAsync() { if (IsSingleChannel) { @@ -112,33 +133,43 @@ public class ExportSetupViewModel : DialogScreen ); var extension = SelectedFormat.GetFileExtension(); - var filter = $"{extension.ToUpperInvariant()} files|*.{extension}"; - var path = _dialogManager.PromptSaveFilePath(filter, defaultFileName); + var path = await dialogManager.PromptSaveFilePathAsync( + [ + new FilePickerFileType($"{extension.ToUpperInvariant()} file") + { + Patterns = [$"*.{extension}"] + } + ], + defaultFileName + ); + if (!string.IsNullOrWhiteSpace(path)) OutputPath = path; } else { - var path = _dialogManager.PromptDirectoryPath(); + var path = await dialogManager.PromptDirectoryPathAsync(); if (!string.IsNullOrWhiteSpace(path)) OutputPath = path; } } - public void ShowAssetsDirPathPrompt() + [RelayCommand] + private async Task ShowAssetsDirPathPromptAsync() { - var path = _dialogManager.PromptDirectoryPath(); + var path = await dialogManager.PromptDirectoryPathAsync(); if (!string.IsNullOrWhiteSpace(path)) AssetsDirPath = path; } - public void Confirm() + [RelayCommand] + private async Task ConfirmAsync() { - // Prompt the output path if it's not set yet + // Prompt the output path if it hasn't been set yet if (string.IsNullOrWhiteSpace(OutputPath)) { - ShowOutputPathPrompt(); + await ShowOutputPathPromptAsync(); // If the output path is still not set, cancel the export if (string.IsNullOrWhiteSpace(OutputPath)) @@ -146,31 +177,14 @@ public class ExportSetupViewModel : DialogScreen } // Persist preferences - _settingsService.LastExportFormat = SelectedFormat; - _settingsService.LastPartitionLimitValue = PartitionLimitValue; - _settingsService.LastMessageFilterValue = MessageFilterValue; - _settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown; - _settingsService.LastShouldDownloadAssets = ShouldDownloadAssets; - _settingsService.LastShouldReuseAssets = ShouldReuseAssets; - _settingsService.LastAssetsDirPath = AssetsDirPath; + settingsService.LastExportFormat = SelectedFormat; + settingsService.LastPartitionLimitValue = PartitionLimitValue; + settingsService.LastMessageFilterValue = MessageFilterValue; + settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown; + settingsService.LastShouldDownloadAssets = ShouldDownloadAssets; + settingsService.LastShouldReuseAssets = ShouldReuseAssets; + settingsService.LastAssetsDirPath = AssetsDirPath; Close(true); } } - -public static class ExportSetupViewModelExtensions -{ - public static ExportSetupViewModel CreateExportSetupViewModel( - this IViewModelFactory factory, - Guild guild, - IReadOnlyList channels - ) - { - var viewModel = factory.CreateExportSetupViewModel(); - - viewModel.Guild = guild; - viewModel.Channels = channels; - - return viewModel; - } -} diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs index 270ed217..d871c3f8 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs @@ -1,49 +1,29 @@ -using DiscordChatExporter.Gui.ViewModels.Framework; +using CommunityToolkit.Mvvm.ComponentModel; +using DiscordChatExporter.Gui.Framework; namespace DiscordChatExporter.Gui.ViewModels.Dialogs; -public class MessageBoxViewModel : DialogScreen +public partial class MessageBoxViewModel : DialogViewModelBase { - public string? Title { get; set; } + [ObservableProperty] + private string? _title = "Title"; - public string? Message { get; set; } + [ObservableProperty] + private string? _message = "Message"; - public bool IsOkButtonVisible { get; set; } = true; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))] + [NotifyPropertyChangedFor(nameof(ButtonsCount))] + private string? _defaultButtonText = "OK"; - public string? OkButtonText { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))] + [NotifyPropertyChangedFor(nameof(ButtonsCount))] + private string? _cancelButtonText = "Cancel"; - public bool IsCancelButtonVisible { get; set; } + public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText); - public string? CancelButtonText { get; set; } + public bool IsCancelButtonVisible => !string.IsNullOrWhiteSpace(CancelButtonText); - public int ButtonsCount => (IsOkButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0); -} - -public static class MessageBoxViewModelExtensions -{ - public static MessageBoxViewModel CreateMessageBoxViewModel( - this IViewModelFactory factory, - string title, - string message, - string? okButtonText, - string? cancelButtonText - ) - { - var viewModel = factory.CreateMessageBoxViewModel(); - - viewModel.Title = title; - viewModel.Message = message; - viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText); - viewModel.OkButtonText = okButtonText; - viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText); - viewModel.CancelButtonText = cancelButtonText; - - return viewModel; - } - - public static MessageBoxViewModel CreateMessageBoxViewModel( - this IViewModelFactory factory, - string title, - string message - ) => factory.CreateMessageBoxViewModel(title, message, "CLOSE", null); + public int ButtonsCount => (IsDefaultButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0); } diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs index d15e2176..9f98d562 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs @@ -2,30 +2,43 @@ using System.Collections.Generic; using System.Linq; using DiscordChatExporter.Core.Utils.Extensions; +using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Models; using DiscordChatExporter.Gui.Services; -using DiscordChatExporter.Gui.ViewModels.Framework; +using DiscordChatExporter.Gui.Utils; +using DiscordChatExporter.Gui.Utils.Extensions; namespace DiscordChatExporter.Gui.ViewModels.Dialogs; -public class SettingsViewModel(SettingsService settingsService) : DialogScreen +public class SettingsViewModel : DialogViewModelBase { + private readonly SettingsService _settingsService; + + private readonly DisposableCollector _eventRoot = new(); + + public SettingsViewModel(SettingsService settingsService) + { + _settingsService = settingsService; + + _eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged)); + } + public bool IsAutoUpdateEnabled { - get => settingsService.IsAutoUpdateEnabled; - set => settingsService.IsAutoUpdateEnabled = value; + get => _settingsService.IsAutoUpdateEnabled; + set => _settingsService.IsAutoUpdateEnabled = value; } public bool IsDarkModeEnabled { - get => settingsService.IsDarkModeEnabled; - set => settingsService.IsDarkModeEnabled = value; + get => _settingsService.IsDarkModeEnabled; + set => _settingsService.IsDarkModeEnabled = value; } public bool IsTokenPersisted { - get => settingsService.IsTokenPersisted; - set => settingsService.IsTokenPersisted = value; + get => _settingsService.IsTokenPersisted; + set => _settingsService.IsTokenPersisted = value; } public IReadOnlyList AvailableThreadInclusions { get; } = @@ -33,13 +46,13 @@ public class SettingsViewModel(SettingsService settingsService) : DialogScreen public ThreadInclusionMode ThreadInclusionMode { - get => settingsService.ThreadInclusionMode; - set => settingsService.ThreadInclusionMode = value; + get => _settingsService.ThreadInclusionMode; + set => _settingsService.ThreadInclusionMode = value; } - // These items have to be non-nullable because WPF ComboBox doesn't allow a null value to be selected - public IReadOnlyList AvailableLocales { get; } = new[] - { + // These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected + public IReadOnlyList AvailableLocales { get; } = + [ // Current locale (maps to null downstream) "", // Locales supported by the Discord app @@ -72,25 +85,35 @@ public class SettingsViewModel(SettingsService settingsService) : DialogScreen "ja-JP", "zh-TW", "ko-KR" - }.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + ]; - // This has to be non-nullable because WPF ComboBox doesn't allow a null value to be selected + // This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected public string Locale { - get => settingsService.Locale ?? ""; + get => _settingsService.Locale ?? ""; // Important to reduce empty strings to nulls, because empty strings don't correspond to valid cultures - set => settingsService.Locale = value.NullIfWhiteSpace(); + set => _settingsService.Locale = value.NullIfWhiteSpace(); } public bool IsUtcNormalizationEnabled { - get => settingsService.IsUtcNormalizationEnabled; - set => settingsService.IsUtcNormalizationEnabled = value; + get => _settingsService.IsUtcNormalizationEnabled; + set => _settingsService.IsUtcNormalizationEnabled = value; } public int ParallelLimit { - get => settingsService.ParallelLimit; - set => settingsService.ParallelLimit = Math.Clamp(value, 1, 10); + get => _settingsService.ParallelLimit; + set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _eventRoot.Dispose(); + } + + base.Dispose(disposing); } } diff --git a/DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs b/DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs deleted file mode 100644 index 1c9fb073..00000000 --- a/DiscordChatExporter.Gui/ViewModels/Framework/DialogManager.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using AsyncKeyedLock; -using MaterialDesignThemes.Wpf; -using Microsoft.Win32; -using Stylet; - -namespace DiscordChatExporter.Gui.ViewModels.Framework; - -public class DialogManager(IViewManager viewManager) : IDisposable -{ - private readonly AsyncNonKeyedLocker _dialogLock = new(); - - public async ValueTask ShowDialogAsync(DialogScreen dialogScreen) - { - var view = viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen); - - void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs) - { - void OnScreenClosed(object? closeSender, EventArgs closeArgs) - { - try - { - openArgs.Session.Close(); - } - catch (InvalidOperationException) - { - // Race condition: dialog is already being closed - } - - dialogScreen.Closed -= OnScreenClosed; - } - dialogScreen.Closed += OnScreenClosed; - } - - using (await _dialogLock.LockAsync()) - { - await DialogHost.Show(view, OnDialogOpened); - return dialogScreen.DialogResult; - } - } - - public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "") - { - var dialog = new SaveFileDialog - { - Filter = filter, - AddExtension = true, - FileName = defaultFilePath, - DefaultExt = Path.GetExtension(defaultFilePath) - }; - - return dialog.ShowDialog() == true ? dialog.FileName : null; - } - - public string? PromptDirectoryPath(string defaultDirPath = "") - { - var dialog = new OpenFolderDialog { InitialDirectory = defaultDirPath }; - return dialog.ShowDialog() == true ? dialog.FolderName : null; - } - - public void Dispose() - { - _dialogLock.Dispose(); - } -} diff --git a/DiscordChatExporter.Gui/ViewModels/Framework/DialogScreen.cs b/DiscordChatExporter.Gui/ViewModels/Framework/DialogScreen.cs deleted file mode 100644 index e6a74360..00000000 --- a/DiscordChatExporter.Gui/ViewModels/Framework/DialogScreen.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Stylet; - -namespace DiscordChatExporter.Gui.ViewModels.Framework; - -public abstract class DialogScreen : PropertyChangedBase -{ - public T? DialogResult { get; private set; } - - public event EventHandler? Closed; - - public void Close(T dialogResult) - { - DialogResult = dialogResult; - Closed?.Invoke(this, EventArgs.Empty); - } -} - -public abstract class DialogScreen : DialogScreen; diff --git a/DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs b/DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs deleted file mode 100644 index f1e5e58c..00000000 --- a/DiscordChatExporter.Gui/ViewModels/Framework/IViewModelFactory.cs +++ /dev/null @@ -1,16 +0,0 @@ -using DiscordChatExporter.Gui.ViewModels.Components; -using DiscordChatExporter.Gui.ViewModels.Dialogs; - -namespace DiscordChatExporter.Gui.ViewModels.Framework; - -// Used to instantiate new view models while making use of dependency injection -public interface IViewModelFactory -{ - DashboardViewModel CreateDashboardViewModel(); - - ExportSetupViewModel CreateExportSetupViewModel(); - - MessageBoxViewModel CreateMessageBoxViewModel(); - - SettingsViewModel CreateSettingsViewModel(); -} diff --git a/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs b/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..a5172724 --- /dev/null +++ b/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs @@ -0,0 +1,127 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.Input; +using DiscordChatExporter.Gui.Framework; +using DiscordChatExporter.Gui.Services; +using DiscordChatExporter.Gui.Utils; +using DiscordChatExporter.Gui.Utils.Extensions; +using DiscordChatExporter.Gui.ViewModels.Components; + +namespace DiscordChatExporter.Gui.ViewModels; + +public partial class MainViewModel( + ViewModelManager viewModelManager, + DialogManager dialogManager, + SnackbarManager snackbarManager, + SettingsService settingsService, + UpdateService updateService +) : ViewModelBase +{ + public string Title { get; } = $"{Program.Name} v{Program.VersionString}"; + + public DashboardViewModel Dashboard { get; } = viewModelManager.CreateDashboardViewModel(); + + private async Task ShowUkraineSupportMessageAsync() + { + if (!settingsService.IsUkraineSupportMessageEnabled) + return; + + var dialog = viewModelManager.CreateMessageBoxViewModel( + "Thank you for supporting Ukraine!", + """ + As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom. + + Click LEARN MORE to find ways that you can help. + """, + "LEARN MORE", + "CLOSE" + ); + + // Disable this message in the future + settingsService.IsUkraineSupportMessageEnabled = false; + settingsService.Save(); + + if (await dialogManager.ShowDialogAsync(dialog) == true) + ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter"); + } + + private async Task CheckForUpdatesAsync() + { + try + { + var updateVersion = await updateService.CheckForUpdatesAsync(); + if (updateVersion is null) + return; + + snackbarManager.Notify($"Downloading update to {Program.Name} v{updateVersion}..."); + await updateService.PrepareUpdateAsync(updateVersion); + + snackbarManager.Notify( + "Update has been downloaded and will be installed when you exit", + "INSTALL NOW", + () => + { + updateService.FinalizeUpdate(true); + + if (Application.Current?.ApplicationLifetime?.TryShutdown(2) != true) + Environment.Exit(2); + } + ); + } + catch + { + // Failure to update shouldn't crash the application + snackbarManager.Notify("Failed to perform application update"); + } + } + + [RelayCommand] + private async Task InitializeAsync() + { + // Reset settings (needed to resolve the default dark mode setting) + settingsService.Reset(); + + // Load settings + settingsService.Load(); + + // Set the correct theme + if (settingsService.IsDarkModeEnabled) + App.SetDarkTheme(); + else + App.SetLightTheme(); + + await ShowUkraineSupportMessageAsync(); + await CheckForUpdatesAsync(); + + // App has just been updated, display the changelog + if ( + settingsService.LastAppVersion is not null + && settingsService.LastAppVersion != Program.Version + ) + { + snackbarManager.Notify( + $"Successfully updated to {Program.Name} v{Program.VersionString}", + "WHAT'S NEW", + () => ProcessEx.StartShellExecute(Program.LatestReleaseUrl) + ); + + settingsService.LastAppVersion = Program.Version; + settingsService.Save(); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + // Save settings + settingsService.Save(); + + // Finalize pending updates + updateService.FinalizeUpdate(false); + } + + base.Dispose(disposing); + } +} diff --git a/DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs b/DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs deleted file mode 100644 index 54c2ae80..00000000 --- a/DiscordChatExporter.Gui/ViewModels/Messages/NotificationMessage.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace DiscordChatExporter.Gui.ViewModels.Messages; - -public record NotificationMessage(string Text); diff --git a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs deleted file mode 100644 index 4e100cd0..00000000 --- a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using System.Threading.Tasks; -using DiscordChatExporter.Gui.Services; -using DiscordChatExporter.Gui.Utils; -using DiscordChatExporter.Gui.ViewModels.Components; -using DiscordChatExporter.Gui.ViewModels.Dialogs; -using DiscordChatExporter.Gui.ViewModels.Framework; -using DiscordChatExporter.Gui.ViewModels.Messages; -using MaterialDesignThemes.Wpf; -using Stylet; - -namespace DiscordChatExporter.Gui.ViewModels; - -public class RootViewModel : Screen, IHandle, IDisposable -{ - private readonly IViewModelFactory _viewModelFactory; - private readonly DialogManager _dialogManager; - private readonly SettingsService _settingsService; - private readonly UpdateService _updateService; - - public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5)); - - public DashboardViewModel Dashboard { get; } - - public RootViewModel( - IViewModelFactory viewModelFactory, - IEventAggregator eventAggregator, - DialogManager dialogManager, - SettingsService settingsService, - UpdateService updateService - ) - { - _viewModelFactory = viewModelFactory; - _dialogManager = dialogManager; - _settingsService = settingsService; - _updateService = updateService; - - eventAggregator.Subscribe(this); - - Dashboard = _viewModelFactory.CreateDashboardViewModel(); - - DisplayName = $"{App.Name} v{App.VersionString}"; - } - - private async Task ShowUkraineSupportMessageAsync() - { - if (!_settingsService.IsUkraineSupportMessageEnabled) - return; - - var dialog = _viewModelFactory.CreateMessageBoxViewModel( - "Thank you for supporting Ukraine!", - """ - As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom. - - Click LEARN MORE to find ways that you can help. - """, - "LEARN MORE", - "CLOSE" - ); - - // Disable this message in the future - _settingsService.IsUkraineSupportMessageEnabled = false; - _settingsService.Save(); - - if (await _dialogManager.ShowDialogAsync(dialog) == true) - ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter"); - } - - private async ValueTask CheckForUpdatesAsync() - { - try - { - var updateVersion = await _updateService.CheckForUpdatesAsync(); - if (updateVersion is null) - return; - - Notifications.Enqueue($"Downloading update to {App.Name} v{updateVersion}..."); - await _updateService.PrepareUpdateAsync(updateVersion); - - Notifications.Enqueue( - "Update has been downloaded and will be installed when you exit", - "INSTALL NOW", - () => - { - _updateService.FinalizeUpdate(true); - RequestClose(); - } - ); - } - catch - { - // Failure to update shouldn't crash the application - Notifications.Enqueue("Failed to perform application update"); - } - } - - public async void OnViewFullyLoaded() - { - await ShowUkraineSupportMessageAsync(); - await CheckForUpdatesAsync(); - } - - protected override void OnViewLoaded() - { - base.OnViewLoaded(); - - _settingsService.Load(); - - // Sync the theme with settings - if (_settingsService.IsDarkModeEnabled) - { - App.SetDarkTheme(); - } - else - { - App.SetLightTheme(); - } - - // App has just been updated, display the changelog - if ( - _settingsService.LastAppVersion is not null - && _settingsService.LastAppVersion != App.Version - ) - { - Notifications.Enqueue( - $"Successfully updated to {App.Name} v{App.VersionString}", - "WHAT'S NEW", - () => ProcessEx.StartShellExecute(App.LatestReleaseUrl) - ); - - _settingsService.LastAppVersion = App.Version; - _settingsService.Save(); - } - } - - protected override void OnClose() - { - base.OnClose(); - - _settingsService.Save(); - _updateService.FinalizeUpdate(false); - } - - public void Handle(NotificationMessage message) => Notifications.Enqueue(message.Text); - - public void Dispose() => Notifications.Dispose(); -} diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml new file mode 100644 index 00000000..69c3b9e4 --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml @@ -0,0 +1,363 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml.cs b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml.cs new file mode 100644 index 00000000..c31982eb --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml.cs @@ -0,0 +1,37 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Interactivity; +using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Gui.Framework; +using DiscordChatExporter.Gui.ViewModels.Components; + +namespace DiscordChatExporter.Gui.Views.Components; + +public partial class DashboardView : UserControl +{ + public DashboardView() => InitializeComponent(); + + private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) + { + DataContext.InitializeCommand.Execute(null); + TokenValueTextBox.Focus(); + } + + private void AvailableGuildsListBox_OnSelectionChanged( + object? sender, + SelectionChangedEventArgs args + ) => DataContext.PullChannelsCommand.Execute(null); + + private void AvailableChannelsTreeView_OnSelectionChanged( + object? sender, + SelectionChangedEventArgs args + ) + { + // Hack: unselect categories because they cannot be exported + foreach (var item in args.AddedItems.OfType().Where(x => x.Channel.IsCategory)) + { + if (AvailableChannelsTreeView.TreeContainerFromItem(item) is TreeViewItem container) + container.IsSelected = false; + } + } +} diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml deleted file mode 100644 index 4d7d031f..00000000 --- a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml +++ /dev/null @@ -1,440 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - documentation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs deleted file mode 100644 index 281bc6f0..00000000 --- a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DiscordChatExporter.Gui.Views.Components; - -public partial class DashboardView -{ - public DashboardView() - { - InitializeComponent(); - } -} diff --git a/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml b/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml new file mode 100644 index 00000000..084e815c --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml.cs b/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml.cs new file mode 100644 index 00000000..08452c12 --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml.cs @@ -0,0 +1,49 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; + +namespace DiscordChatExporter.Gui.Views.Controls; + +public partial class HyperLink : UserControl +{ + public static readonly StyledProperty TextProperty = + TextBlock.TextProperty.AddOwner(); + + public static readonly StyledProperty CommandProperty = + Button.CommandProperty.AddOwner(); + + public static readonly StyledProperty CommandParameterProperty = + Button.CommandParameterProperty.AddOwner(); + + public HyperLink() => InitializeComponent(); + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public ICommand? Command + { + get => GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + public object? CommandParameter + { + get => GetValue(CommandParameterProperty); + set => SetValue(CommandParameterProperty, value); + } + + private void TextBlock_OnPointerReleased(object? sender, PointerReleasedEventArgs args) + { + if (Command is null) + return; + + if (!Command.CanExecute(CommandParameter)) + return; + + Command.Execute(CommandParameter); + } +} diff --git a/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml b/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml deleted file mode 100644 index f9ca6ad6..00000000 --- a/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml.cs b/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml.cs deleted file mode 100644 index 23239e12..00000000 --- a/DiscordChatExporter.Gui/Views/Controls/RevealablePasswordBox.xaml.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Windows; - -namespace DiscordChatExporter.Gui.Views.Controls; - -public partial class RevealablePasswordBox -{ - public static readonly DependencyProperty PasswordProperty = DependencyProperty.Register( - nameof(Password), - typeof(string), - typeof(RevealablePasswordBox), - new FrameworkPropertyMetadata( - string.Empty, - FrameworkPropertyMetadataOptions.BindsTwoWayByDefault - ) - ); - - public static readonly DependencyProperty IsRevealedProperty = DependencyProperty.Register( - nameof(IsRevealed), - typeof(bool), - typeof(RevealablePasswordBox), - new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.None) - ); - - public string Password - { - get => (string)GetValue(PasswordProperty); - set => SetValue(PasswordProperty, value); - } - - public bool IsRevealed - { - get => (bool)GetValue(IsRevealedProperty); - set => SetValue(IsRevealedProperty, value); - } - - public RevealablePasswordBox() - { - InitializeComponent(); - } -} diff --git a/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml b/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml new file mode 100644 index 00000000..67067f73 --- /dev/null +++ b/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -