Add PowerKit and replace custom utility extensions (#1525)

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2026-04-19 23:10:45 +03:00
committed by GitHub
parent 757daa7a60
commit 7456f0fe3a
75 changed files with 133 additions and 603 deletions

View File

@@ -34,6 +34,7 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="Onova" Version="2.6.13" />
<PackageVersion Include="Polly" Version="8.6.6" />
<PackageVersion Include="PowerKit" Version="1.1.0" />
<PackageVersion Include="RazorBlade" Version="0.11.0" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
<PackageVersion Include="Superpower" Version="3.1.0" />

View File

@@ -20,6 +20,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="PowerKit" PrivateAssets="all" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
</ItemGroup>

View File

@@ -5,11 +5,11 @@ using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using JsonExtensions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;

View File

@@ -5,11 +5,11 @@ using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering;
using FluentAssertions;
using JsonExtensions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;

View File

@@ -8,6 +8,7 @@ using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;

View File

@@ -4,8 +4,8 @@ using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions;
using FluentAssertions;
using PowerKit.Extensions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;

View File

@@ -1,9 +1,9 @@
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using PowerKit.Extensions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -22,7 +22,7 @@ public class HtmlForwardSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.ContainAll("Forwarded", @"¯\_(ツ)_/¯", "12/29/2025 2:14 PM");
}

View File

@@ -8,6 +8,7 @@ using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;

View File

@@ -1,9 +1,9 @@
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using PowerKit.Extensions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -22,11 +22,14 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.Contain("Default timestamp: 2/12/2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -39,8 +42,11 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().ReplaceWhiteSpace().Should().Contain("Short time timestamp: 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.Text().ReplaceWhiteSpace(' ').Should().Contain("Short time timestamp: 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -53,8 +59,11 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().ReplaceWhiteSpace().Should().Contain("Long time timestamp: 1:36:12 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.Text().ReplaceWhiteSpace(' ').Should().Contain("Long time timestamp: 1:36:12 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -67,8 +76,11 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().ReplaceWhiteSpace().Should().Contain("Short date timestamp: 2/12/2023");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.Text().ReplaceWhiteSpace(' ').Should().Contain("Short date timestamp: 2/12/2023");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -83,11 +95,14 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.Contain("Long date timestamp: Sunday, February 12, 2023");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -102,11 +117,14 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -121,11 +139,14 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -140,11 +161,14 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.Contain("Relative timestamp: 2/12/2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]

View File

@@ -4,10 +4,10 @@ using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using JsonExtensions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;

View File

@@ -3,10 +3,10 @@ using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Partitioning;
using FluentAssertions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -17,7 +17,7 @@ public class PartitioningSpecs
public async Task I_can_export_a_channel_with_partitioning_based_on_message_count()
{
// Arrange
using var dir = TempDir.Create();
using var dir = TempDirectory.Create();
var filePath = Path.Combine(dir.Path, "output.html");
// Act
@@ -38,7 +38,7 @@ public class PartitioningSpecs
public async Task I_can_export_a_channel_with_partitioning_based_on_file_size()
{
// Arrange
using var dir = TempDir.Create();
using var dir = TempDirectory.Create();
var filePath = Path.Combine(dir.Path, "output.html");
// Act

View File

@@ -1,7 +1,7 @@
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using FluentAssertions;
using PowerKit.Extensions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -16,7 +16,7 @@ public class PlainTextForwardSpecs
// Assert
document
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.ContainAll("{Forwarded Message}", @"¯\_(ツ)_/¯", "12/28/2025 10:52 PM");
}

View File

@@ -7,6 +7,7 @@ using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -17,7 +18,7 @@ public class SelfContainedSpecs
public async Task I_can_export_a_channel_and_download_all_referenced_assets()
{
// Arrange
using var dir = TempDir.Create();
using var dir = TempDirectory.Create();
var filePath = Path.Combine(dir.Path, "output.html");
// Act

View File

@@ -1,19 +0,0 @@
using System.Text;
namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
internal static class StringExtensions
{
extension(string str)
{
public string ReplaceWhiteSpace(string replacement = " ")
{
var buffer = new StringBuilder(str.Length);
foreach (var ch in str)
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
return buffer.ToString();
}
}
}

View File

@@ -1,36 +0,0 @@
using System;
using System.IO;
using System.Reflection;
namespace DiscordChatExporter.Cli.Tests.Utils;
internal partial class TempDir(string path) : IDisposable
{
public string Path { get; } = path;
public void Dispose()
{
try
{
Directory.Delete(Path, true);
}
catch (DirectoryNotFoundException) { }
}
}
internal partial class TempDir
{
public static TempDir Create()
{
var dirPath = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory(),
"Temp",
Guid.NewGuid().ToString()
);
Directory.CreateDirectory(dirPath);
return new TempDir(dirPath);
}
}

View File

@@ -1,37 +0,0 @@
using System;
using System.IO;
using System.Reflection;
namespace DiscordChatExporter.Cli.Tests.Utils;
internal partial class TempFile(string path) : IDisposable
{
public string Path { get; } = path;
public void Dispose()
{
try
{
File.Delete(Path);
}
catch (FileNotFoundException) { }
}
}
internal partial class TempFile
{
public static TempFile Create()
{
var dirPath = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory(),
"Temp"
);
Directory.CreateDirectory(dirPath);
var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
return new TempFile(filePath);
}
}

View File

@@ -5,7 +5,7 @@ using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;

View File

@@ -3,7 +3,7 @@ using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;

View File

@@ -7,7 +7,7 @@ using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;

View File

@@ -5,7 +5,7 @@ using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;

View File

@@ -5,7 +5,7 @@ using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;

View File

@@ -11,6 +11,7 @@
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
<PackageReference Include="Deorcify" PrivateAssets="all" />
<PackageReference Include="Gress" />
<PackageReference Include="PowerKit" PrivateAssets="all" />
<PackageReference Include="Spectre.Console" />
</ItemGroup>

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -2,8 +2,8 @@
using System.IO;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -2,8 +2,8 @@
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -3,8 +3,8 @@ using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
@@ -47,9 +47,7 @@ public partial record Embed
var kind =
json.GetPropertyOrNull("type")
?.GetStringOrNull()
?.Pipe(s =>
Enum.TryParse<EmbedKind>(s, true, out var result) ? result : (EmbedKind?)null
)
.Pipe(s => Enum.ParseOrNull<EmbedKind>(s, true))
?? EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
@@ -58,7 +56,7 @@ public partial record Embed
var color = json.GetPropertyOrNull("color")
?.GetInt32OrNull()
?.Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha();
.WithFullAlpha();
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();

View File

@@ -1,8 +1,8 @@
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -1,7 +1,7 @@
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -2,8 +2,8 @@
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -4,8 +4,8 @@ using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -1,5 +1,5 @@
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -1,8 +1,8 @@
using System.Drawing;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -18,7 +18,7 @@ public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHas
var color = json.GetPropertyOrNull("color")
?.GetInt32OrNull()
?.Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha()
.WithFullAlpha()
.NullIf(c => c.ToRgb() <= 0);
return new Role(id, name, position, color);

View File

@@ -1,8 +1,8 @@
using System;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -1,7 +1,7 @@
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;

View File

@@ -11,10 +11,10 @@ using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using Gress;
using JsonExtensions.Http;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord;
@@ -59,12 +59,12 @@ public class DiscordClient(
{
var remainingRequestCount = response
.Headers.TryGetValue("X-RateLimit-Remaining")
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
?.Pipe(s => int.ParseOrNull(s, CultureInfo.InvariantCulture));
var resetAfterDelay = response
.Headers.TryGetValue("X-RateLimit-Reset-After")
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
.Pipe(TimeSpan.FromSeconds);
?.Pipe(s => double.ParseOrNull(s, CultureInfo.InvariantCulture))
?.Pipe(TimeSpan.FromSeconds);
// If this was the last request available before hitting the rate limit,
// wait out the reset time so that future requests can succeed.
@@ -161,7 +161,7 @@ public class DiscordClient(
$"""
Request to '{url}' failed: {response
.StatusCode.ToString()
.ToSpaceSeparatedWords()
.SeparateWords(' ')
.ToLowerInvariant()}.
Response content: {await response.Content.ReadAsStringAsync(
cancellationToken

View File

@@ -6,6 +6,7 @@
<PackageReference Include="Gress" />
<PackageReference Include="JsonExtensions" />
<PackageReference Include="Polly" />
<PackageReference Include="PowerKit" PrivateAssets="all" />
<PackageReference Include="RazorBlade" />
<PackageReference Include="Superpower" />
<PackageReference Include="WebMarkupMin.Core" />

View File

@@ -5,7 +5,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Exporting;

View File

@@ -9,7 +9,7 @@ using System.Threading.Tasks;
using System.Web;
using AsyncKeyedLock;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Exporting;

View File

@@ -9,7 +9,7 @@ using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Exporting;

View File

@@ -7,7 +7,7 @@ using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Exporting;

View File

@@ -7,7 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Exporting;

View File

@@ -9,8 +9,8 @@ using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Writing;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Exporting;
@@ -55,7 +55,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
);
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHexString());
_writer.WriteBoolean("isBot", user.IsBot);
if (includeRoles)
@@ -109,7 +109,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteString("id", role.Id.ToString());
_writer.WriteString("name", role.Name);
_writer.WriteString("color", role.Color?.ToHex());
_writer.WriteString("color", role.Color?.ToHexString());
_writer.WriteNumber("position", role.Position);
_writer.WriteEndObject();
@@ -281,7 +281,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
);
if (embed.Color is not null)
_writer.WriteString("color", embed.Color.Value.ToHex());
_writer.WriteString("color", embed.Color.Value.ToHexString());
if (embed.Author is not null)
{

View File

@@ -6,6 +6,7 @@
@using DiscordChatExporter.Core.Discord.Data.Embeds
@using DiscordChatExporter.Core.Markdown.Parsing
@using DiscordChatExporter.Core.Utils.Extensions
@using PowerKit.Extensions
@inherits RazorBlade.HtmlTemplate

View File

@@ -3,7 +3,7 @@ using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Exporting;

View File

@@ -1,7 +1,7 @@
using System.Globalization;
using System.Linq;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Exporting;

View File

@@ -5,7 +5,7 @@ using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Markdown.Parsing;

View File

@@ -1,24 +0,0 @@
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class AsyncCollectionExtensions
{
extension<T>(IAsyncEnumerable<T> asyncEnumerable)
{
private async ValueTask<IReadOnlyList<T>> CollectAsync()
{
var list = new List<T>();
await foreach (var i in asyncEnumerable)
list.Add(i);
return list;
}
public ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter() =>
asyncEnumerable.CollectAsync().GetAwaiter();
}
}

View File

@@ -1,40 +0,0 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class CollectionExtensions
{
extension<T>(T obj)
{
public IEnumerable<T> ToSingletonEnumerable()
{
yield return obj;
}
}
extension<T>(IEnumerable<T?> source)
where T : class
{
public IEnumerable<T> WhereNotNull()
{
foreach (var o in source)
{
if (o is not null)
yield return o;
}
}
}
extension<T>(IEnumerable<T?> source)
where T : struct
{
public IEnumerable<T> WhereNotNull()
{
foreach (var o in source)
{
if (o is not null)
yield return o.Value;
}
}
}
}

View File

@@ -1,17 +0,0 @@
using System.Drawing;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class ColorExtensions
{
extension(Color color)
{
public Color WithAlpha(int alpha) => Color.FromArgb(alpha, color);
public Color ResetAlpha() => color.WithAlpha(255);
public int ToRgb() => color.ToArgb() & 0xffffff;
public string ToHex() => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
}
}

View File

@@ -1,34 +0,0 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class ExceptionExtensions
{
extension(Exception exception)
{
private void PopulateChildren(ICollection<Exception> children)
{
if (exception is AggregateException aggregateException)
{
foreach (var innerException in aggregateException.InnerExceptions)
{
children.Add(innerException);
PopulateChildren(innerException, children);
}
}
else if (exception.InnerException is not null)
{
children.Add(exception.InnerException);
PopulateChildren(exception.InnerException, children);
}
}
public IReadOnlyList<Exception> GetSelfAndChildren()
{
var children = new List<Exception> { exception };
PopulateChildren(exception, children);
return children;
}
}
}

View File

@@ -1,21 +0,0 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class GenericExtensions
{
extension<TIn>(TIn input)
{
public TOut Pipe<TOut>(Func<TIn, TOut> transform) => transform(input);
}
extension<T>(T value)
where T : struct
{
public T? NullIf(Func<T, bool> predicate) => !predicate(value) ? value : null;
public T? NullIfDefault() =>
value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
}
}

View File

@@ -1,12 +0,0 @@
using System.Net.Http.Headers;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class HttpExtensions
{
extension(HttpHeaders headers)
{
public string? TryGetValue(string name) =>
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
}
}

View File

@@ -1,48 +0,0 @@
using System;
using System.IO;
using System.Text;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class PathExtensions
{
// This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS).
// We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters,
// not filesystem-specific characters. It's possible to use, for example, an NTFS drive on Linux,
// which would make some additional characters invalid that are otherwise valid on Linux.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1452
private static readonly char[] InvalidFileNameChars =
[
'\0', // Null character - invalid on all filesystems
'/', // Path separator on Unix and Windows
'\\', // Path separator on Windows
':', // Reserved on Windows (drive letters, NTFS streams)
'*', // Wildcard on Windows
'?', // Wildcard on Windows
'"', // Reserved on Windows
'<', // Redirection on Windows
'>', // Redirection on Windows
'|', // Pipe on Windows
];
extension(Path)
{
public static string EscapeFileName(string path)
{
var buffer = new StringBuilder(path.Length);
foreach (var c in path)
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
// File names cannot end with a dot on Windows
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
if (OperatingSystem.IsWindows())
{
while (buffer.Length > 0 && buffer[^1] == '.')
buffer.Remove(buffer.Length - 1, 1);
}
return buffer.ToString();
}
}
}

View File

@@ -1,34 +0,0 @@
using System.Text;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class StringExtensions
{
extension(string str)
{
public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null;
public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str;
public string ToSpaceSeparatedWords()
{
var builder = new StringBuilder(str.Length * 2);
foreach (var c in str)
{
if (char.IsUpper(c) && builder.Length > 0)
builder.Append(' ');
builder.Append(c);
}
return builder.ToString();
}
}
extension(StringBuilder builder)
{
public StringBuilder AppendIfNotEmpty(char value) =>
builder.Length > 0 ? builder.Append(value) : builder;
}
}

View File

@@ -1,20 +0,0 @@
using System;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class TimeSpanExtensions
{
extension(TimeSpan value)
{
public TimeSpan Clamp(TimeSpan min, TimeSpan max)
{
if (value < min)
return min;
if (value > max)
return max;
return value;
}
}
}

View File

@@ -5,9 +5,9 @@ using System.Net.Http;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Utils.Extensions;
using Polly;
using Polly.Retry;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Utils;
@@ -24,7 +24,7 @@ public static class Http
private static bool IsRetryableException(Exception exception) =>
exception
.GetSelfAndChildren()
.GetSelfAndDescendants()
.Any(ex =>
ex is TimeoutException or SocketException or AuthenticationException
|| ex is HttpRequestException hrex

View File

@@ -2,7 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Utils;

View File

@@ -7,13 +7,13 @@ using Avalonia.Platform;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Localization;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using Material.Styles.Themes;
using Microsoft.Extensions.DependencyInjection;
using PowerKit;
using PowerKit.Extensions;
namespace DiscordChatExporter.Gui;
@@ -22,7 +22,7 @@ public class App : Application, IDisposable
private readonly ServiceProvider _services;
private readonly SettingsService _settingsService;
private readonly DisposableCollector _eventRoot = new();
private readonly IDisposable _eventSubscription;
private bool _isDisposed;
@@ -54,7 +54,7 @@ public class App : Application, IDisposable
_settingsService = _services.GetRequiredService<SettingsService>();
// Re-initialize the theme when the user changes it
_eventRoot.Add(
_eventSubscription = Disposable.Merge(
_settingsService.WatchProperty(
o => o.Theme,
v =>
@@ -131,7 +131,7 @@ public class App : Application, IDisposable
_isDisposed = true;
_eventRoot.Dispose();
_eventSubscription.Dispose();
_services.Dispose();
}
}

View File

@@ -41,6 +41,7 @@
<PackageReference Include="Material.Icons.Avalonia" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Onova" />
<PackageReference Include="PowerKit" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
</ItemGroup>

View File

@@ -3,20 +3,19 @@ using System.Globalization;
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.Utils.Extensions;
using PowerKit;
using PowerKit.Extensions;
namespace DiscordChatExporter.Gui.Localization;
public partial class LocalizationManager : ObservableObject, IDisposable
{
private readonly DisposableCollector _eventRoot = new();
private readonly IDisposable _eventSubscription;
public LocalizationManager(SettingsService settingsService)
{
_eventRoot.Add(settingsService.WatchProperty(o => o.Language, v => Language = v, true));
_eventRoot.Add(
_eventSubscription = Disposable.Merge(
settingsService.WatchProperty(o => o.Language, v => Language = v, true),
this.WatchProperty(
o => o.Language,
_ =>
@@ -66,7 +65,7 @@ public partial class LocalizationManager : ObservableObject, IDisposable
return $"Missing localization for '{key}'";
}
public void Dispose() => _eventRoot.Dispose();
public void Dispose() => _eventSubscription.Dispose();
}
public partial class LocalizationManager

View File

@@ -1,10 +0,0 @@
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();
}

View File

@@ -1,28 +0,0 @@
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<IDisposable> _items = [];
public void Add(IDisposable item)
{
lock (_lock)
{
_items.Add(item);
}
}
public void Dispose()
{
lock (_lock)
{
_items.DisposeAll();
_items.Clear();
}
}
}

View File

@@ -1,15 +0,0 @@
using System.Windows.Input;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class CommandExtensions
{
extension(ICommand command)
{
public void ExecuteIfCan(object? parameter = null)
{
if (command.CanExecute(parameter))
command.Execute(parameter);
}
}
}

View File

@@ -1,31 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class DisposableExtensions
{
extension(IEnumerable<IDisposable> disposables)
{
public void DisposeAll()
{
var exceptions = default(List<Exception>);
foreach (var disposable in disposables)
{
try
{
disposable.Dispose();
}
catch (Exception ex)
{
(exceptions ??= []).Add(ex);
}
}
if (exceptions?.Any() == true)
throw new AggregateException(exceptions);
}
}
}

View File

@@ -1,55 +0,0 @@
using System;
using System.ComponentModel;
using System.Linq.Expressions;
using System.Reflection;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class NotifyPropertyChangedExtensions
{
extension<TOwner>(TOwner owner)
where TOwner : INotifyPropertyChanged
{
public IDisposable WatchProperty<TProperty>(
Expression<Func<TOwner, TProperty>> propertyExpression,
Action<TProperty> callback,
bool watchInitialValue = false
)
{
var memberExpression = propertyExpression.Body as MemberExpression;
if (memberExpression?.Member is not PropertyInfo property)
throw new ArgumentException("Provided expression must reference a property.");
var getValue = propertyExpression.Compile();
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (
string.IsNullOrWhiteSpace(args.PropertyName)
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
)
{
callback(getValue(owner));
}
}
owner.PropertyChanged += OnPropertyChanged;
if (watchInitialValue)
callback(getValue(owner));
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
}
public IDisposable WatchAllProperties(Action callback, bool watchInitialValues = false)
{
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
owner.PropertyChanged += OnPropertyChanged;
if (watchInitialValues)
callback();
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
}
}
}

View File

@@ -1,17 +0,0 @@
using System.Diagnostics;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class ProcessExtensions
{
extension(Process)
{
public static void StartShellExecute(string path)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo(path) { UseShellExecute = true };
process.Start();
}
}
}

View File

@@ -10,15 +10,14 @@ 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.Localization;
using DiscordChatExporter.Gui.Models;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.Utils.Extensions;
using Gress;
using Gress.Completable;
using PowerKit;
using PowerKit.Extensions;
namespace DiscordChatExporter.Gui.ViewModels.Components;
@@ -29,7 +28,7 @@ public partial class DashboardViewModel : ViewModelBase
private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService;
private readonly DisposableCollector _eventRoot = new();
private readonly IDisposable _eventSubscription;
private readonly AutoResetProgressMuxer _progressMuxer;
private DiscordClient? _discord;
@@ -50,14 +49,11 @@ public partial class DashboardViewModel : ViewModelBase
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
_eventRoot.Add(
_eventSubscription = Disposable.Merge(
Progress.WatchProperty(
o => o.Current,
_ => OnPropertyChanged(nameof(IsProgressIndeterminate))
)
);
_eventRoot.Add(
),
SelectedChannels.WatchProperty(
o => o.Count,
_ => ExportCommand.NotifyCanExecuteChanged()
@@ -332,7 +328,7 @@ public partial class DashboardViewModel : ViewModelBase
{
if (disposing)
{
_eventRoot.Dispose();
_eventSubscription.Dispose();
}
base.Dispose(disposing);

View File

@@ -10,10 +10,10 @@ 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.Localization;
using DiscordChatExporter.Gui.Services;
using PowerKit.Extensions;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs;

View File

@@ -1,13 +1,12 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Localization;
using DiscordChatExporter.Gui.Models;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.Utils.Extensions;
using PowerKit;
using PowerKit.Extensions;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
@@ -15,7 +14,7 @@ public class SettingsViewModel : DialogViewModelBase
{
private readonly SettingsService _settingsService;
private readonly DisposableCollector _eventRoot = new();
private readonly IDisposable _eventSubscription;
public SettingsViewModel(
SettingsService settingsService,
@@ -25,7 +24,9 @@ public class SettingsViewModel : DialogViewModelBase
_settingsService = settingsService;
LocalizationManager = localizationManager;
_eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
_eventSubscription = Disposable.Merge(
_settingsService.WatchAllProperties(OnAllPropertiesChanged)
);
}
public LocalizationManager LocalizationManager { get; }
@@ -140,7 +141,7 @@ public class SettingsViewModel : DialogViewModelBase
{
if (disposing)
{
_eventRoot.Dispose();
_eventSubscription.Dispose();
}
base.Dispose(disposing);

View File

@@ -7,6 +7,7 @@ using DiscordChatExporter.Gui.Localization;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels.Components;
using PowerKit.Extensions;
namespace DiscordChatExporter.Gui.ViewModels;

View File

@@ -4,8 +4,8 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels.Components;
using PowerKit.Extensions;
namespace DiscordChatExporter.Gui.Views.Components;

View File

@@ -3,7 +3,7 @@ using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using DiscordChatExporter.Gui.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Gui.Views.Controls;