diff --git a/Directory.Packages.props b/Directory.Packages.props
index ff5d2be..6e128b3 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -18,7 +18,7 @@
-
+
diff --git a/Extensions/CryptoExtensions/Base64Stuff.cs b/Extensions/CryptoExtensions/Base64Stuff.cs
index 88e2d07..6503bf2 100644
--- a/Extensions/CryptoExtensions/Base64Stuff.cs
+++ b/Extensions/CryptoExtensions/Base64Stuff.cs
@@ -1,5 +1,9 @@
-using System.IO;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
@@ -16,7 +20,7 @@ public static class Base64Stuff
public static string ToBase64HashString(this string textToHash)
{
using var ms = new MemoryStream(Encoding.UTF8.GetBytes(textToHash));
- var hashBytes = ms.ToHashBytes(); // Reuse your existing extension
+ var hashBytes = ms.ToHashBytes();
return Convert.ToBase64String(hashBytes);
}
@@ -29,17 +33,10 @@ public static string ToBase64HashString(this string textToHash)
public static async Task ToBase64HashStringAsync(this string textToHash, CancellationToken cancellationToken = default)
{
await using var ms = new MemoryStream(Encoding.UTF8.GetBytes(textToHash));
- var hashBytes = await ms.ToHashBytesAsync(cancellationToken).ConfigureAwait(false); // Async reuse
+ var hashBytes = await ms.ToHashBytesAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToBase64String(hashBytes);
}
-
-
-
-
-
-
-
///
/// Hash the stream to a Base64 string using .
///
@@ -64,5 +61,52 @@ public static async Task ToBase64HashStringAsync(this Stream stream, Can
}
+ ///
+ /// Produces a stable, order-independent Base64 hash of any object.
+ /// Collections of elements are sorted before hashing,
+ /// so two objects that differ only in list ordering produce the same hash.
+ /// Useful as a cache key or equality fingerprint for filter objects.
+ ///
+ /// The object to hash.
+ /// Base64 hash string.
+ public static string ToStableHash(this object objectToHash)
+ {
+ var json = JsonSerializer.Serialize(objectToHash, CacheKeyOptions);
+ return json.ToBase64HashString();
+ }
+
+ private static readonly JsonSerializerOptions CacheKeyOptions = new()
+ {
+ Converters = { new SortedListConverterFactory() }
+ };
+ private class SortedListConverterFactory : JsonConverterFactory
+ {
+ public override bool CanConvert(Type typeToConvert) =>
+ typeToConvert.IsGenericType &&
+ typeToConvert.GetGenericTypeDefinition() == typeof(List<>) &&
+ typeToConvert.GetGenericArguments()[0].IsAssignableTo(typeof(IComparable));
+
+ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ var elementType = typeToConvert.GetGenericArguments()[0];
+ return (JsonConverter)Activator.CreateInstance(
+ typeof(SortedListConverter<>).MakeGenericType(elementType))!;
+ }
+
+ private class SortedListConverter : JsonConverter?> where T : IComparable
+ {
+ public override List? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ JsonSerializer.Deserialize>(ref reader);
+
+ public override void Write(Utf8JsonWriter writer, List? value, JsonSerializerOptions options)
+ {
+ if (value is null) { writer.WriteNullValue(); return; }
+ writer.WriteStartArray();
+ foreach (var item in value.Order())
+ JsonSerializer.Serialize(writer, item);
+ writer.WriteEndArray();
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/Extensions/FileExtensions/DirectoryStuff.cs b/Extensions/FileExtensions/DirectoryStuff.cs
index d6b2e77..2bab36d 100644
--- a/Extensions/FileExtensions/DirectoryStuff.cs
+++ b/Extensions/FileExtensions/DirectoryStuff.cs
@@ -64,18 +64,21 @@ private static void TryDeleteEmptySubfolders(string path, bool isHome = true)
///
- /// Ensures that contains exactly the given files by *file name*.
- ///
- /// - Existing files with matching names are kept as-is.
- /// - Files not listed are deleted.
- /// - Files listed but missing are copied from their source paths.
- /// - Duplicate file names ?? First file wins, others are ignored
- ///
- /// Note: Matching is done by file name only (not content or path).
+ /// Synchronizes to contain exactly the files specified by , matched by file name only.
+ ///
+ /// **Operation:**
+ /// - Files in target matching a source file name are kept as-is (never overwritten).
+ /// - Files in target with no matching source file name are deleted.
+ /// - Source files not yet present in target are copied to the target folder root.
+ ///
+ /// **Limitations:**
+ /// - Matching is by file name only (case-sensitive on Linux, case-insensitive on Windows).
+ /// - If multiple source files have the same name, the first one is used; others are ignored.
+ /// - Deletions that fail due to file locks are logged but do not stop the operation (via TryDelete).
///
- /// absolute target folder path
- /// absolute file paths for source files
- /// cancellation
+ /// Target folder to synchronize (will be created if missing)
+ /// Absolute file paths of files that should exist in target folder
+ /// Cancellation token
public static void SyncFolderByFileName(this DirectoryInfo targetFolder, IEnumerable sourceFiles, CancellationToken cancellationToken = default)
{
targetFolder.Create();
@@ -86,7 +89,7 @@ public static void SyncFolderByFileName(this DirectoryInfo targetFolder, IEnumer
x => Path.Combine(targetFolder.FullName, x.Name));
var existingFiles = targetFolder
- .GetFiles("*.*", SearchOption.AllDirectories)
+ .GetFiles("*", SearchOption.TopDirectoryOnly)
.ToList();
//delete obsolete files
diff --git a/Extensions/NetworkExtensions/PathExtensions.cs b/Extensions/NetworkExtensions/PathExtensions.cs
new file mode 100644
index 0000000..bd0c5fa
--- /dev/null
+++ b/Extensions/NetworkExtensions/PathExtensions.cs
@@ -0,0 +1,28 @@
+using System.IO;
+
+namespace aemarcoCommons.Extensions.NetworkExtensions;
+
+public record NetworkPathAccessibilityResult(
+ bool IsAccessible,
+ Exception? Exception = null);
+
+public static class PathExtensions
+{
+ public static NetworkPathAccessibilityResult CheckNetworkPathAccessibility(this DirectoryInfo directory)
+ {
+ try
+ {
+ var networkRoot = Path.GetPathRoot(directory.FullName);
+ var isAccessible = !string.IsNullOrEmpty(networkRoot) && Directory.Exists(networkRoot);
+ return new NetworkPathAccessibilityResult(isAccessible);
+ }
+ catch (IOException ex)
+ {
+ return new NetworkPathAccessibilityResult(false, ex);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ return new NetworkPathAccessibilityResult(false, ex);
+ }
+ }
+}
diff --git a/Tests/ExtensionsTests/CryptoExtensionsTests/Base64StuffTests.cs b/Tests/ExtensionsTests/CryptoExtensionsTests/Base64StuffTests.cs
index 88cf3f4..94013c0 100644
--- a/Tests/ExtensionsTests/CryptoExtensionsTests/Base64StuffTests.cs
+++ b/Tests/ExtensionsTests/CryptoExtensionsTests/Base64StuffTests.cs
@@ -9,10 +9,39 @@ public class Base64StuffTests
[TestCase("oMyDear", "d7gPbeNldisHAEjR9Zq7OQ==")]
public void ToBase64HashString_Returns_Correctly(string text, string expected)
{
-
var result = text.ToBase64HashString();
result.ShouldBe(expected);
}
+
+ // ReSharper disable NotAccessedPositionalProperty.Local
+ private record StableHashTarget(List? Ids = null, List? Tags = null);
+ // ReSharper restore NotAccessedPositionalProperty.Local
+
+ [TestCase(new[] { 1, 2, 3 }, new[] { 3, 1, 2 })]
+ [TestCase(new[] { 10, 1, 5 }, new[] { 5, 10, 1 })]
+ public void ToStableHash_IntListDifferentOrder_SameHash(int[] a, int[] b)
+ {
+ new StableHashTarget([.. a]).ToStableHash()
+ .ShouldBe(new StableHashTarget([.. b]).ToStableHash());
+ }
+
+ [TestCase(new[] { "x", "y", "z" }, new[] { "z", "x", "y" })]
+ [TestCase(new[] { "banana", "apple" }, new[] { "apple", "banana" })]
+ public void ToStableHash_StringListDifferentOrder_SameHash(string[] a, string[] b)
+ {
+ new StableHashTarget(null, [.. a]).ToStableHash()
+ .ShouldBe(new StableHashTarget(null, [.. b]).ToStableHash());
+ }
+
+ [TestCase(new[] { 1, 2, 3 }, new[] { 1, 2, 4 })]
+ [TestCase(new[] { 1, 2, 3 }, new[] { 1, 2 })]
+ public void ToStableHash_DifferentContent_DifferentHash(int[] a, int[] b)
+ {
+ new StableHashTarget([.. a]).ToStableHash()
+ .ShouldNotBe(new StableHashTarget([.. b]).ToStableHash());
+ }
+
+
}
\ No newline at end of file
diff --git a/Toolbox/CustomDatastructures/DropOutStack.cs b/Toolbox/CustomDatastructures/DropOutStack.cs
index 1c7aab1..9cebfab 100644
--- a/Toolbox/CustomDatastructures/DropOutStack.cs
+++ b/Toolbox/CustomDatastructures/DropOutStack.cs
@@ -1,6 +1,4 @@
-using System.Linq;
-
-namespace aemarcoCommons.Toolbox.CustomDatastructures;
+namespace aemarcoCommons.Toolbox.CustomDatastructures;
public class DropOutStack
{
diff --git a/Toolbox/GeoTools/GeoServiceSunInfo.cs b/Toolbox/GeoTools/GeoServiceSunInfo.cs
index 4a16bd4..a1d423b 100644
--- a/Toolbox/GeoTools/GeoServiceSunInfo.cs
+++ b/Toolbox/GeoTools/GeoServiceSunInfo.cs
@@ -1,7 +1,6 @@
using Newtonsoft.Json;
using System;
using System.Collections.Concurrent;
-using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
diff --git a/Toolbox/GlobalUsings.cs b/Toolbox/GlobalUsings.cs
new file mode 100644
index 0000000..10ac625
--- /dev/null
+++ b/Toolbox/GlobalUsings.cs
@@ -0,0 +1,3 @@
+// Global using directives
+
+global using System.Linq;
\ No newline at end of file
diff --git a/Toolbox/Ioc/AddAllImplementationsExtensions.cs b/Toolbox/Ioc/AddAllImplementationsExtensions.cs
new file mode 100644
index 0000000..1e7f7d4
--- /dev/null
+++ b/Toolbox/Ioc/AddAllImplementationsExtensions.cs
@@ -0,0 +1,37 @@
+using Microsoft.Extensions.DependencyInjection;
+using System.Reflection;
+
+namespace aemarcoCommons.Toolbox.Ioc;
+
+public static class AddAllImplementationsExtensions
+{
+ public static IServiceCollection AddTransientImplementations(
+ this IServiceCollection services,
+ params Assembly[] assemblies) =>
+ services.AddImplementations(ServiceLifetime.Transient, assemblies);
+
+ public static IServiceCollection AddScopedImplementations(
+ this IServiceCollection services,
+ params Assembly[] assemblies) =>
+ services.AddImplementations(ServiceLifetime.Scoped, assemblies);
+
+ public static IServiceCollection AddSingletonImplementations(
+ this IServiceCollection services,
+ params Assembly[] assemblies) =>
+ services.AddImplementations(ServiceLifetime.Singleton, assemblies);
+
+ private static IServiceCollection AddImplementations(
+ this IServiceCollection services,
+ ServiceLifetime lifetime,
+ Assembly[] assemblies)
+ {
+ var types = assemblies
+ .SelectMany(a => a.GetTypes())
+ .Where(t =>
+ typeof(TInterface).IsAssignableFrom(t) &&
+ t is { IsAbstract: false, IsInterface: false });
+ foreach (var type in types)
+ services.Add(ServiceDescriptor.Describe(typeof(TInterface), type, lifetime));
+ return services;
+ }
+}
diff --git a/Toolbox/Ioc/DecoratorRegistrationExtensions.cs b/Toolbox/Ioc/DecoratorRegistrationExtensions.cs
index ed27602..214858e 100644
--- a/Toolbox/Ioc/DecoratorRegistrationExtensions.cs
+++ b/Toolbox/Ioc/DecoratorRegistrationExtensions.cs
@@ -1,7 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
-using System.Linq;
namespace aemarcoCommons.Toolbox.Ioc;
diff --git a/Toolbox/Mime/MimeMap.cs b/Toolbox/Mime/MimeMap.cs
index a45d1a1..fb267e3 100644
--- a/Toolbox/Mime/MimeMap.cs
+++ b/Toolbox/Mime/MimeMap.cs
@@ -1,21 +1,20 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
-using System.Linq;
namespace aemarcoCommons.Toolbox.Mime;
-public class MimeMap
+public static class MimeMap
{
private const string Dot = ".";
private const string QuestionMark = "?";
private const string DefaultMimeType = "application/octet-stream";
- private static readonly Lazy> MappingsExtToMime = new Lazy>(BuildMappingsExtToMime);
- private static readonly Lazy> MappingsMimeToExt = new Lazy>(BuildMappingsMimeToExt);
+ private static readonly Lazy> MappingsExtToMime = new(BuildMappingsExtToMime);
+ private static readonly Lazy> MappingsMimeToExt = new(BuildMappingsMimeToExt);
- public static string[] Extensions => MappingsExtToMime.Value.Keys.ToArray();
- public static string[] MimeTypes => MappingsMimeToExt.Value.Values.ToArray();
+ public static string[] Extensions => [.. MappingsExtToMime.Value.Keys];
+ public static string[] MimeTypes => [.. MappingsMimeToExt.Value.Values];
///
@@ -78,7 +77,7 @@ public static string GetMimeType(string fileNameOrExtension) =>
: null;
}
- private static IDictionary BuildMappingsExtToMime()
+ private static Dictionary BuildMappingsExtToMime()
{
// ReSharper disable StringLiteralTypo
var mappings = new Dictionary(StringComparer.OrdinalIgnoreCase)
@@ -733,7 +732,7 @@ private static IDictionary BuildMappingsExtToMime()
// ReSharper restore StringLiteralTypo
return mappings;
}
- private static IDictionary BuildMappingsMimeToExt()
+ private static Dictionary BuildMappingsMimeToExt()
{
// ReSharper disable StringLiteralTypo
var mappings = new Dictionary(StringComparer.OrdinalIgnoreCase)
diff --git a/ToolboxConsole/Power/Json.cs b/ToolboxConsole/Power/Json.cs
index ef2b4ae..2f72d91 100644
--- a/ToolboxConsole/Power/Json.cs
+++ b/ToolboxConsole/Power/Json.cs
@@ -6,15 +6,9 @@ namespace aemarcoCommons.ToolboxConsole;
public static partial class PowerConsole
{
- private static readonly JsonSerializerOptions JsonSerializerOptions = new()
- {
- WriteIndented = true
- };
public static void WriteAsJson(object obj, JsonSerializerOptions? options = null)
{
- var json = JsonSerializer.Serialize(
- obj,
- options ?? JsonSerializerOptions);
+ var json = JsonSerializer.Serialize(obj, options);
AnsiConsole.Write(new JsonText(json));
AnsiConsole.WriteLine();
}
diff --git a/ToolboxConsole/Power/Selection.cs b/ToolboxConsole/Power/Selection.cs
index 4de4ac1..1114f16 100644
--- a/ToolboxConsole/Power/Selection.cs
+++ b/ToolboxConsole/Power/Selection.cs
@@ -13,7 +13,7 @@ public static T EnsureSelection(string header, IEnumerable selectable, Fun
{
return AnsiConsole.Prompt(
new SelectionPrompt()
- .Title($"[purple]{header}[/]")
+ .Title($"[purple]{header.EscapeMarkup()}[/]")
.UseConverter(displayProperty)
.AddChoices([.. selectable]));
}
diff --git a/ToolboxFluentValidation/RuleBuilders/StringRuleBuilderExtensions.cs b/ToolboxFluentValidation/RuleBuilders/StringRuleBuilderExtensions.cs
index f57c80c..b6e5c99 100644
--- a/ToolboxFluentValidation/RuleBuilders/StringRuleBuilderExtensions.cs
+++ b/ToolboxFluentValidation/RuleBuilders/StringRuleBuilderExtensions.cs
@@ -5,6 +5,22 @@ namespace aemarcoCommons.ToolboxFluentValidation;
//https://docs.fluentvalidation.net/en/latest/custom-validators.html
public static class StringRuleBuilderExtensions
{
+
+ public static IRuleBuilderOptions BeValidAbsoluteUri(
+ this IRuleBuilder ruleBuilder, bool? trailingSlash = false)
+ {
+ return ruleBuilder
+ .Must(text => IsAbsoluteWebUri(text, trailingSlash))
+ .WithMessage(
+ trailingSlash.HasValue
+ ? trailingSlash.Value
+ ? "'{PropertyName}' must be a valid Web-Uri with a trailing slash"
+ : "'{PropertyName}' must be a valid Web-Uri without a trailing slash"
+ : "'{PropertyName}' must be a valid Web-Uri");
+ }
+
+
+
public static IRuleBuilderOptions BeValidAbsoluteWebUri(
this IRuleBuilder ruleBuilder, bool allowTrailingSlash = false)
{
@@ -26,10 +42,12 @@ public static IRuleBuilderOptions BeValidAbsoluteWebUri(
- private static bool IsAbsoluteWebUri(string url, bool allowTrailingSlash) =>
+ private static bool IsAbsoluteWebUri(string? url, bool? trailingSlash) =>
!string.IsNullOrWhiteSpace(url) &&
- (!url.EndsWith('/') || allowTrailingSlash) &&
- Uri.TryCreate(url, UriKind.Absolute, out Uri? uriResult) &&
+ (!trailingSlash.HasValue ||
+ (trailingSlash.Value && url.EndsWith('/')) ||
+ (!trailingSlash.Value && !url.EndsWith('/'))) &&
+ Uri.TryCreate(url, UriKind.Absolute, out var uriResult) &&
(uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
diff --git a/ToolboxFluentValidation/RuleBuilders/StringRuleBuilderSecretsExtensions.cs b/ToolboxFluentValidation/RuleBuilders/StringRuleBuilderSecretsExtensions.cs
new file mode 100644
index 0000000..b652185
--- /dev/null
+++ b/ToolboxFluentValidation/RuleBuilders/StringRuleBuilderSecretsExtensions.cs
@@ -0,0 +1,16 @@
+namespace aemarcoCommons.ToolboxFluentValidation.RuleBuilders;
+
+public static class StringRuleBuilderSecretsExtensions
+{
+ public static IRuleBuilderOptions BeConfiguredSecret(
+ this IRuleBuilder ruleBuilder)
+ {
+ return ruleBuilder
+ .Must(text =>
+ text is not null &&
+ text != "secret")
+ .WithMessage("Secret '{PropertyName}' must be configured");
+ }
+
+
+}
diff --git a/ToolboxImage/Extensions/ImageExtensions.cs b/ToolboxImage/Extensions/ImageExtensions.cs
index f6212a3..96b7daf 100644
--- a/ToolboxImage/Extensions/ImageExtensions.cs
+++ b/ToolboxImage/Extensions/ImageExtensions.cs
@@ -19,6 +19,16 @@ namespace aemarcoCommons.ToolboxImage.Extensions;
public static class ImageExtensions
{
+ //read
+ public static async Task IdentifyImageAsync(this FileInfo file)
+ {
+ var result = await Image.IdentifyAsync(file.FullName);
+ return result;
+ }
+
+
+ //update
+
///
/// Creates a resized clone of the specified image using the given parameters.
///
diff --git a/ToolboxImage/GlobalUsings.cs b/ToolboxImage/GlobalUsings.cs
index 1aad611..39f1627 100644
--- a/ToolboxImage/GlobalUsings.cs
+++ b/ToolboxImage/GlobalUsings.cs
@@ -28,4 +28,5 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
-global using System.Linq;
\ No newline at end of file
+global using System.Linq;
+global using System.Threading.Tasks;
\ No newline at end of file
diff --git a/ToolboxWeb/Authorization/LanIpAddress.cs b/ToolboxWeb/Authorization/LanIpAddress.cs
index 94cfbb5..26ab224 100644
--- a/ToolboxWeb/Authorization/LanIpAddress.cs
+++ b/ToolboxWeb/Authorization/LanIpAddress.cs
@@ -144,8 +144,8 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext
return;
}
- if (_logger.IsEnabled(LogLevel.Debug))
- _logger.LogDebug("Access granted to {RemoteIp}", remoteIp);
+
+ _logger.LogDebug("Access granted to {RemoteIp}", remoteIp);
context.Succeed(requirement);
}
diff --git a/version.json b/version.json
index c601aa1..246ee10 100644
--- a/version.json
+++ b/version.json
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
- "version": "10.0",
+ "version": "10.1",
"publicReleaseRefSpec": [
"^refs/heads/main",
"^refs/heads/v\\d+(?:\\.\\d+)?$"