From ca0b306b374b20d84feff458c33044ef492012ad Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 29 Mar 2026 09:40:04 +0200 Subject: [PATCH 1/6] BytecodeApi.Wpf.Cui Example publish profile --- .../PublishProfiles/FolderProfile.pubxml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 Playground.Wpf.Cui/Properties/PublishProfiles/FolderProfile.pubxml diff --git a/Playground.Wpf.Cui/Properties/PublishProfiles/FolderProfile.pubxml b/Playground.Wpf.Cui/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..ae7e5de --- /dev/null +++ b/Playground.Wpf.Cui/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,15 @@ + + + + Release + Any CPU + ..\$Build\BytecodeApi.Wpf.Cui Example + FileSystem + <_TargetId>Folder + win-x64 + false + false + false + false + + \ No newline at end of file From cc440e1b658fbd296df252c116205b54a5fa5db4 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Thu, 2 Apr 2026 09:56:30 +0200 Subject: [PATCH 2/6] + BytecodeApi.IterationGuard --- BytecodeApi/IterationGuard.cs | 71 ++++++++++++++++++++++++ BytecodeApi/IterationGuardErrorReason.cs | 16 ++++++ BytecodeApi/IterationGuardException.cs | 34 ++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 BytecodeApi/IterationGuard.cs create mode 100644 BytecodeApi/IterationGuardErrorReason.cs create mode 100644 BytecodeApi/IterationGuardException.cs diff --git a/BytecodeApi/IterationGuard.cs b/BytecodeApi/IterationGuard.cs new file mode 100644 index 0000000..5b99e31 --- /dev/null +++ b/BytecodeApi/IterationGuard.cs @@ -0,0 +1,71 @@ +using System.Diagnostics; + +namespace BytecodeApi; + +/// +/// Class that can be used to guard loops by specifying a maximum number of iterations and/or a timeout. +/// If any of these is exceeded, an infinite loop is assumed and an exception is thrown. +/// +public sealed class IterationGuard +{ + private readonly Stopwatch Stopwatch; + /// + /// Gets the maximum number of iterations, or , if no maximum number of iterations is specified. + /// + public int? MaxIterations { get; } + /// + /// Gets the timeout for the operation, or , if no timeout is specified. + /// + public TimeSpan? Timeout { get; } + /// + /// Gets the current number of iterations that have been executed. + /// This property is incremented by calling the method. + /// + public int Iterations { get; private set; } + /// + /// Gets the time elapsed since the creation of this instance. + /// + public TimeSpan Elapsed => Stopwatch.Elapsed; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum number of iterations, or , to specify no maximum number of iterations. + /// The timeout for the operation, or , to specify no timeout. + public IterationGuard(int? maxIterations = null, TimeSpan? timeout = null) + { + if (maxIterations == null && timeout == null) throw Throw.Argument(nameof(maxIterations), $"At least one of {nameof(maxIterations)} or {nameof(timeout)} must be specified."); + if (maxIterations != null) Check.ArgumentOutOfRangeEx.Greater0(maxIterations.Value); + if (timeout != null) Check.ArgumentOutOfRangeEx.Greater0(timeout.Value); + + MaxIterations = maxIterations; + Timeout = timeout; + Stopwatch = Stopwatch.StartNew(); + } + + /// + /// Advances the iteration guard by one step and checks for iteration or timeout limits. + /// Call this method at the start of each iteration to enforce iteration and timeout constraints. + /// If either limit is exceeded, an infinite loop is assumed and an exception is thrown. + /// + public void Next() + { + if (Iterations++ > MaxIterations) + { + throw new IterationGuardException(IterationGuardErrorReason.MaxIterationsExceeded, Iterations, Elapsed, $"Maximum number of {MaxIterations} iterations exceeded."); + } + + if (Elapsed > Timeout) + { + throw new IterationGuardException(IterationGuardErrorReason.TimeoutExceeded, Iterations, Elapsed, $"Timeout of {Timeout} exceeded."); + } + } + /// + /// Resets the state of this instance. + /// + public void Reset() + { + Iterations = 0; + Stopwatch.Restart(); + } +} \ No newline at end of file diff --git a/BytecodeApi/IterationGuardErrorReason.cs b/BytecodeApi/IterationGuardErrorReason.cs new file mode 100644 index 0000000..857d37b --- /dev/null +++ b/BytecodeApi/IterationGuardErrorReason.cs @@ -0,0 +1,16 @@ +namespace BytecodeApi; + +/// +/// Specifies the reason why an iteration guard operation failed. +/// +public enum IterationGuardErrorReason +{ + /// + /// The maximum number of iterations was exceeded. + /// + MaxIterationsExceeded, + /// + /// The timeout was exceeded. + /// + TimeoutExceeded +} \ No newline at end of file diff --git a/BytecodeApi/IterationGuardException.cs b/BytecodeApi/IterationGuardException.cs new file mode 100644 index 0000000..4241d66 --- /dev/null +++ b/BytecodeApi/IterationGuardException.cs @@ -0,0 +1,34 @@ +namespace BytecodeApi; + +/// +/// The exception that is thrown when a loop guarded by an instance is assumed to have become an infinite loop by exceeding the specified maximum number of iterations or a timeout. +/// +public sealed class IterationGuardException : InvalidOperationException +{ + /// + /// Gets the reason for the iteration guard error. + /// + public IterationGuardErrorReason Reason { get; } + /// + /// Gets the number of iterations that have been executed. + /// + public int Iterations { get; } + /// + /// Gets the time elapsed since the creation of this instance, before the exception was thrown. + /// + public TimeSpan Elapsed { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The reason for the iteration guard error. + /// The number of iterations that have been executed. + /// The time elapsed since the creation of this instance, before the exception was thrown. + /// The message that describes the error. + public IterationGuardException(IterationGuardErrorReason reason, int iterations, TimeSpan elapsed, string message) : base(message) + { + Reason = reason; + Iterations = iterations; + Elapsed = elapsed; + } +} \ No newline at end of file From c4c0e94fc4ae2afccb5aaedd59d74f1c32e32bde Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Fri, 24 Apr 2026 08:02:31 +0200 Subject: [PATCH 3/6] + Validate.Website --- BytecodeApi.Wpf.Cui/Controls/UiPasswordBox.cs | 4 +-- .../Services/TreeViewItemService.cs | 2 +- BytecodeApi/Data/TimeoutAttribute.cs | 1 + BytecodeApi/Validate.cs | 34 +++++++++++++++++-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/BytecodeApi.Wpf.Cui/Controls/UiPasswordBox.cs b/BytecodeApi.Wpf.Cui/Controls/UiPasswordBox.cs index 2417bfb..3e8963f 100644 --- a/BytecodeApi.Wpf.Cui/Controls/UiPasswordBox.cs +++ b/BytecodeApi.Wpf.Cui/Controls/UiPasswordBox.cs @@ -32,11 +32,11 @@ public class UiPasswordBox : Control /// /// Identifies the .CopyPassword dependency property. This field is read-only. /// - public static readonly RoutedUICommand CopyPasswordCommand = new RoutedUICommand("Copy Password", "CopyPassword", typeof(UiPasswordBox), [new KeyGesture(Key.C, ModifierKeys.Control)]); + public static readonly RoutedUICommand CopyPasswordCommand = new("Copy Password", "CopyPassword", typeof(UiPasswordBox), [new KeyGesture(Key.C, ModifierKeys.Control)]); /// /// Identifies the .CutPassword dependency property. This field is read-only. /// - public static readonly RoutedUICommand CutPasswordCommand = new RoutedUICommand("Cut Password", "CutPassword", typeof(UiPasswordBox), [new KeyGesture(Key.C, ModifierKeys.Control)]); + public static readonly RoutedUICommand CutPasswordCommand = new("Cut Password", "CutPassword", typeof(UiPasswordBox), [new KeyGesture(Key.C, ModifierKeys.Control)]); private static readonly DependencyPropertyKey PreviewPasswordPropertyKey = DependencyProperty.RegisterReadOnly(nameof(PreviewPassword), new FrameworkPropertyMetadata(false)); /// /// Identifies the dependency property. This field is read-only. diff --git a/BytecodeApi.Wpf/Services/TreeViewItemService.cs b/BytecodeApi.Wpf/Services/TreeViewItemService.cs index 957c9da..1863f83 100644 --- a/BytecodeApi.Wpf/Services/TreeViewItemService.cs +++ b/BytecodeApi.Wpf/Services/TreeViewItemService.cs @@ -54,7 +54,7 @@ private static void OnMouseTransition(object sender, MouseEventArgs e) oldItem.InvalidateProperty(IsMouseDirectlyOverItemProperty); } - Mouse.DirectlyOver?.RaiseEvent(new RoutedEventArgs(UpdateOverItemEvent)); + Mouse.DirectlyOver?.RaiseEvent(new(UpdateOverItemEvent)); } } private static void OnUpdateOverItem(object sender, RoutedEventArgs e) diff --git a/BytecodeApi/Data/TimeoutAttribute.cs b/BytecodeApi/Data/TimeoutAttribute.cs index a1179c8..1441908 100644 --- a/BytecodeApi/Data/TimeoutAttribute.cs +++ b/BytecodeApi/Data/TimeoutAttribute.cs @@ -3,6 +3,7 @@ /// /// Represents an attribute that specifies a timeout for an operation. /// +[AttributeUsage(AttributeTargets.All)] public sealed class TimeoutAttribute : Attribute { /// diff --git a/BytecodeApi/Validate.cs b/BytecodeApi/Validate.cs index 8c98e4d..8b2370c 100644 --- a/BytecodeApi/Validate.cs +++ b/BytecodeApi/Validate.cs @@ -1,5 +1,6 @@ using BytecodeApi.Extensions; using System.ComponentModel.DataAnnotations; +using System.Net.Mail; using System.Text.RegularExpressions; namespace BytecodeApi; @@ -188,7 +189,33 @@ public static bool FileName([NotNullWhen(true)] string? str) /// public static bool Url([NotNullWhen(true)] string? str) { - return Uri.TryCreate(str, UriKind.Absolute, out Uri? result) && result.Scheme is "http" or "https"; + return Uri.TryCreate(str, UriKind.Absolute, out Uri? uri) && uri.Scheme is "http" or "https"; + } + /// + /// Validates a that is a website (with or without http(s):// or www.) + /// + /// The to be validated. + /// + /// , if validation of succeeded; + /// otherwise, . + /// + public static bool Website([NotNullWhen(true)] string? str) + { + if (str.IsNullOrWhiteSpace()) + { + return false; + } + + if (!str.Contains("://")) + { + str = $"http://{str}"; + } + + return + Uri.TryCreate(str, UriKind.Absolute, out Uri? uri) && + Uri.CheckHostName(uri.Host) == UriHostNameType.Dns && + uri.Scheme.ToLower() is "http" or "https" && + uri.Host.TrimStartString("www.").Contains('.'); } /// /// Validates a that is an email address. @@ -200,7 +227,10 @@ public static bool Url([NotNullWhen(true)] string? str) /// public static bool EmailAddress([NotNullWhen(true)] string? str) { - return str != null && new EmailAddressAttribute().IsValid(str); + return + MailAddress.TryCreate(str, out MailAddress? mailAddress) && + mailAddress.Address == str && // John Doe might still be parsed as valid email address! + mailAddress.Host.Contains('.'); } /// /// Validates a that is an . Both IPv4 and IPv6 values are validated. From e398bcbf04165ba9c1ba56e58af8ab041fcdd2d0 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Wed, 29 Apr 2026 20:38:56 +0200 Subject: [PATCH 4/6] * OperatingSystemInfo.JavaVersion improvement --- BytecodeApi.Win32/SystemInfo/OperatingSystemInfo.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BytecodeApi.Win32/SystemInfo/OperatingSystemInfo.cs b/BytecodeApi.Win32/SystemInfo/OperatingSystemInfo.cs index d5a9ce5..3b3d960 100644 --- a/BytecodeApi.Win32/SystemInfo/OperatingSystemInfo.cs +++ b/BytecodeApi.Win32/SystemInfo/OperatingSystemInfo.cs @@ -210,8 +210,8 @@ public static string? JavaVersion } else { - Match match = Regex.Match(result.Output, "^\\s*java version \"(.+)\""); - field = match.Success ? match.Groups[1].Value : ""; + Match match = Regex.Match(result.Output, "^\\s*(java|openjdk) version \"(?.+)\""); + field = match.Success ? match.Groups["Version"].Value : ""; } } From c554f20bd64d9103c116bd9fbf5b1099c93280a8 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Thu, 7 May 2026 19:31:30 +0200 Subject: [PATCH 5/6] * BytecodeApi.Wpf.Dialogs.FileDialogs as fluent API --- BytecodeApi.Wpf/Dialogs/FileDialogs.cs | 705 +++++++++++++++----- BytecodeApi.Wpf/Dialogs/IconPickerDialog.cs | 6 +- BytecodeApi/Internal/Check.cs | 10 +- BytecodeApi/Internal/ExceptionMessages.cs | 1 + 4 files changed, 561 insertions(+), 161 deletions(-) diff --git a/BytecodeApi.Wpf/Dialogs/FileDialogs.cs b/BytecodeApi.Wpf/Dialogs/FileDialogs.cs index b56f405..00631f4 100644 --- a/BytecodeApi.Wpf/Dialogs/FileDialogs.cs +++ b/BytecodeApi.Wpf/Dialogs/FileDialogs.cs @@ -1,255 +1,646 @@ -using BytecodeApi.Extensions; +using BytecodeApi.Extensions; using BytecodeApi.IO; using Microsoft.Win32; -using WinFormsDialogResult = System.Windows.Forms.DialogResult; -using WinFormsFolderBrowserDialog = System.Windows.Forms.FolderBrowserDialog; +using System.Windows; namespace BytecodeApi.Wpf.Dialogs; /// -/// Helper class for UI dialogs, such as Open and Save for files and directories. +/// Class to display UI dialogs, such as Open and Save for files and directories. /// public static class FileDialogs { /// - /// Displays an open file dialog with a filter that allows any file to be opened. Returns a representing the full path to the selected file and , if selection has been canceled by the user. + /// Creates an open file dialog. /// /// - /// A representing the full path to the selected file and , if selection has been canceled by the user. + /// A that can be used to configure and display the dialog. /// - public static string? Open() + public static OpenFileDialogBuilder Open() { - return Open(null); + return new(); } /// - /// Displays an open file dialog with a filter that allows any file with one of the specified extensions to be opened. The extension description is retrieved using the class. Returns a representing the full path to the selected file and , if selection has been canceled by the user. + /// Creates an open file dialog that opens multiple files. /// - /// The collection of extensions that are allowed to be opened. The extension description is retrieved using the class. /// - /// A representing the full path to the selected file and , if selection has been canceled by the user. + /// A that can be used to configure and display the dialog. /// - public static string? Open(params string[]? extensions) + public static OpenMultipleFilesDialogBuilder OpenMultiple() { - return Open(extensions, null); + return new(); } /// - /// Displays an open file dialog with a filter that allows any file with one of the specified extensions to be opened. Returns a representing the full path to the selected file and , if selection has been canceled by the user. + /// Creates an open folder dialog. /// - /// The collection of extensions that are allowed to be opened. - /// The description to be used. If , the class is used to retrieve the description. /// - /// A representing the full path to the selected file and , if selection has been canceled by the user. + /// A that can be used to configure and display the dialog. /// - public static string? Open(string[]? extensions, string? extensionsDescription) + public static OpenFolderDialogBuilder OpenFolder() { - return Open(extensions, extensionsDescription, null); + return new(); } /// - /// Displays an open file dialog with a filter that allows any file with one of the specified extensions to be opened. Returns a representing the full path to the selected file and , if selection has been canceled by the user. + /// Creates an open folder dialog that opens multiple folders. /// - /// The collection of extensions that are allowed to be opened. - /// The description to be used. If , the class is used to retrieve the description. - /// A specifying the initial directory. /// - /// A representing the full path to the selected file and , if selection has been canceled by the user. + /// A that can be used to configure and display the dialog. /// - public static string? Open(string[]? extensions, string? extensionsDescription, string? initialDirectory) + public static OpenMultipleFoldersDialogBuilder OpenMultipleFolders() { - OpenFileDialog dialog = new() - { - Filter = GetFilter(extensions, extensionsDescription), - InitialDirectory = initialDirectory ?? "" - }; - - return dialog.ShowDialog() == true ? dialog.FileName : null; + return new(); } /// - /// Displays an open file dialog with a filter that allows any file to be opened. Returns a [] representing the full path to all selected files and , if selection has been canceled by the user. + /// Creates an icon selection dialog. /// /// - /// A [] representing the full path to all selected files and , if selection has been canceled by the user. + /// A that can be used to configure and display the dialog. /// - public static string[]? OpenMultiple() + public static SelectIconDialogBuilder SelectIcon() { - return OpenMultiple(null); + return new(); } /// - /// Displays an open file dialog with a filter that allows any file with one of the specified extensions to be opened. The extension description is retrieved using the class. Returns a [] representing the full path to all selected files and , if selection has been canceled by the user. + /// Creates a save file dialog. /// - /// The collection of extensions that are allowed to be opened. The extension description is retrieved using the class. /// - /// A [] representing the full path to all selected files and , if selection has been canceled by the user. + /// A that can be used to configure and display the dialog. /// - public static string[]? OpenMultiple(params string[]? extensions) + public static SaveFileDialogBuilder Save() { - return OpenMultiple(extensions, null); + return new(); } - /// - /// Displays an open file dialog with a filter that allows any file with one of the specified extensions to be opened. Returns a [] representing the full path to all selected files and , if selection has been canceled by the user. - /// - /// The collection of extensions that are allowed to be opened. - /// The description to be used. If , the class is used to retrieve the description. - /// - /// A [] representing the full path to all selected files and , if selection has been canceled by the user. - /// - public static string[]? OpenMultiple(string[]? extensions, string? extensionsDescription) + + private static string GetFilter(IEnumerable fileTypes) { - return OpenMultiple(extensions, extensionsDescription, null); + return fileTypes.Any() + ? fileTypes.Select(fileType => GetFilter(fileType.Extensions, fileType.Description)).AsString("|") + : GetFilter(null, null); } - /// - /// Displays an open file dialog with a filter that allows any file with one of the specified extensions to be opened. Returns a [] representing the full path to all selected files and , if selection has been canceled by the user. - /// - /// The collection of extensions that are allowed to be opened. - /// The description to be used. If , the class is used to retrieve the description. - /// A specifying the initial directory. - /// - /// A [] representing the full path to all selected files and , if selection has been canceled by the user. - /// - public static string[]? OpenMultiple(string[]? extensions, string? extensionsDescription, string? initialDirectory) + private static string GetFilter(string?[]? extensions, string? description) { - OpenFileDialog dialog = new() + if (extensions.IsNullOrEmpty()) + { + return $"{description ?? "All Files"}|*.*"; + } + else { - Filter = GetFilter(extensions, extensionsDescription), - Multiselect = true, - InitialDirectory = initialDirectory ?? "" - }; + string[] descriptions = description != null + ? [description] + : extensions.Select(extension => new FileExtensionInfo(extension ?? "").FriendlyDocName).ExceptNull().Distinct().ToArray(); - return dialog.ShowDialog() == true ? dialog.FileNames : null; + return $"{(descriptions.Length == 1 ? descriptions.First() : "Miscellaneous Files")}|{extensions.Select(extension => $"*.{NormalizeExtension(extension)}").AsString(";")}"; + } } - /// - /// Displays a folder browser dialog and returns a representing the full path to the selected directory and , if selection has been canceled by the user. - /// - /// - /// A representing the full path to the selected directory and , if selection has been canceled by the user. - /// - public static string? OpenFolder() + private static string? NormalizeExtension(string? extension) { - return OpenFolder(null); + return extension.ToNullIfEmptyOrWhiteSpace()?.Trim().TrimStart('.').ToLower(); } - /// - /// Displays a folder browser dialog and returns a representing the full path to the selected directory and , if selection has been canceled by the user. - /// - /// A specifying the folder to start browsing from. - /// - /// A representing the full path to the selected directory and , if selection has been canceled by the user. - /// - public static string? OpenFolder(string? initialDirectory) + private static bool ShowDialog(CommonDialog dialog, Window? owner) { - WinFormsFolderBrowserDialog dialog = new() + if (owner != null) { - InitialDirectory = initialDirectory ?? "" - }; - - return dialog.ShowDialog() == WinFormsDialogResult.OK ? dialog.SelectedPath : null; + return dialog.ShowDialog(owner) == true; + } + else + { + return dialog.ShowDialog() == true; + } } + /// - /// Displays an that allows an icon from a resource file to be selected. Returns a representing the full path to the selected file and , if selection has been canceled by the user. The selected icon index is written to the parameter. + /// Provides a fluent builder for configuring and displaying an open file dialog. /// - /// When this method returns, a that contains the index of the selected icon; otherwise, -1. - /// - /// Returns a representing the full path to the selected file and , if selection has been canceled by the user. The selected icon index is written to the parameter. - /// - public static string? SelectIcon(out int index) + public sealed class OpenFileDialogBuilder { - IconPickerDialog dialog = new(); - if (dialog.ShowDialog() != true) + private Window? _Owner; + private readonly List _FileTypes; + private string? _InitialDirectory; + + internal OpenFileDialogBuilder() + { + _FileTypes = []; + } + + /// + /// Sets the owner of the dialog. + /// + /// A to use as the owner of the dialog, or to not specify an owner. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenFileDialogBuilder Owner(Window? owner) + { + _Owner = owner; + return this; + } + /// + /// Specifies the file extensions that are allowed to be opened. + /// This method can be called multiple times to specify multiple sets of extensions to choose from. + /// + /// The extensions that are allowed to be opened. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenFileDialogBuilder FileType(params string[] extensions) + { + return FileType(extensions, null); + } + /// + /// Specifies the file extensions that are allowed to be opened. + /// This method can be called multiple times to specify multiple sets of extensions to choose from. + /// + /// The extensions that are allowed to be opened. + /// The description to be used. If set to , the description is retrieved automatically from the shell. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenFileDialogBuilder FileType(string[] extensions, string? description) { - dialog.Reset(); + Check.ArgumentNull(extensions); + Check.ArgumentEx.ArrayElementsRequired(extensions); + Check.ArgumentEx.ArrayValuesNotNull(extensions); + Check.ArgumentEx.ArrayValuesNotStringEmptyOrWhiteSpace(extensions); + + _FileTypes.Add(new(extensions.Select(extension => NormalizeExtension(extension)!).ToArray(), description)); + return this; } + /// + /// Sets the initial directory for the dialog. If set to , the dialog will open in the last used directory or a default directory determined by the system. + /// + /// A specifying the initial directory for the dialog. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenFileDialogBuilder InitialDirectory(string? initialDirectory) + { + _InitialDirectory = initialDirectory; + return this; + } + /// + /// Displays the dialog. If the user clicks the OK button, this method returns and the selected file name is returned in the parameter. + /// + /// When this method returns, contains the selected file name if the user clicked the OK button; otherwise, . + /// + /// , if the user clicked the OK button; + /// otherwise, . + /// + public bool Show([NotNullWhen(true)] out string? fileName) + { + OpenFileDialog dialog = new() + { + Filter = GetFilter(_FileTypes), + InitialDirectory = _InitialDirectory ?? "" + }; - index = dialog.IconIndex; - return dialog.FileName; + if (ShowDialog(dialog, _Owner)) + { + fileName = dialog.FileName; + return true; + } + else + { + fileName = null; + return false; + } + } } + /// - /// Displays a save file dialog and automatically adds an extension to the filename, if the user omits an extension. The extension is taken from the parameter. + /// Provides a fluent builder for configuring and displaying an open file dialog that opens multiple files. /// - /// A specifying the initial filename that can be changed by the user. - /// - /// A representing the full path to the saved file and , if selection has been canceled by the user. - /// - public static string? Save(string? fileName) + public sealed class OpenMultipleFilesDialogBuilder { - return Save(fileName, null); + private Window? _Owner; + private readonly List _FileTypes; + private string? _InitialDirectory; + + internal OpenMultipleFilesDialogBuilder() + { + _FileTypes = []; + } + + /// + /// Sets the owner of the dialog. + /// + /// A to use as the owner of the dialog, or to not specify an owner. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenMultipleFilesDialogBuilder Owner(Window? owner) + { + _Owner = owner; + return this; + } + /// + /// Specifies the file extensions that are allowed to be opened. + /// This method can be called multiple times to specify multiple sets of extensions to choose from. + /// + /// The extensions that are allowed to be opened. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenMultipleFilesDialogBuilder FileType(params string[] extensions) + { + return FileType(extensions, null); + } + /// + /// Specifies the file extensions that are allowed to be opened. + /// This method can be called multiple times to specify multiple sets of extensions to choose from. + /// + /// The extensions that are allowed to be opened. + /// The description to be used. If set to , the description is retrieved automatically from the shell. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenMultipleFilesDialogBuilder FileType(string[] extensions, string? description) + { + Check.ArgumentNull(extensions); + Check.ArgumentEx.ArrayElementsRequired(extensions); + Check.ArgumentEx.ArrayValuesNotNull(extensions); + Check.ArgumentEx.ArrayValuesNotStringEmptyOrWhiteSpace(extensions); + + _FileTypes.Add(new(extensions.Select(extension => NormalizeExtension(extension)!).ToArray(), description)); + return this; + } + /// + /// Sets the initial directory for the dialog. If set to , the dialog will open in the last used directory or a default directory determined by the system. + /// + /// A specifying the initial directory for the dialog. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenMultipleFilesDialogBuilder InitialDirectory(string? initialDirectory) + { + _InitialDirectory = initialDirectory; + return this; + } + /// + /// Displays the dialog. If the user clicks the OK button, this method returns and the selected file names are returned in the parameter. + /// + /// When this method returns, contains the selected file names if the user clicked the OK button; otherwise, an empty array. + /// + /// , if the user clicked the OK button; + /// otherwise, . + /// + public bool Show(out string[] fileNames) + { + OpenFileDialog dialog = new() + { + Filter = GetFilter(_FileTypes), + Multiselect = true, + InitialDirectory = _InitialDirectory ?? "" + }; + + if (ShowDialog(dialog, _Owner)) + { + fileNames = dialog.FileNames; + return true; + } + else + { + fileNames = []; + return false; + } + } } + /// - /// Displays a save file dialog and automatically adds an extension to the filename, if the user omits an extension. The extension description is retrieved using the class. + /// Provides a fluent builder for configuring and displaying an open folder dialog. /// - /// A specifying the initial filename that can be changed by the user. - /// A specifying the extension to be added. - /// - /// A representing the full path to the saved file and , if selection has been canceled by the user. - /// - public static string? Save(string? fileName, string? extension) + public sealed class OpenFolderDialogBuilder { - return Save(fileName, extension, null); + private Window? _Owner; + private string? _InitialDirectory; + + internal OpenFolderDialogBuilder() + { + } + + /// + /// Sets the owner of the dialog. + /// + /// A to use as the owner of the dialog, or to not specify an owner. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenFolderDialogBuilder Owner(Window? owner) + { + _Owner = owner; + return this; + } + /// + /// Sets the initial directory for the dialog. If set to , the dialog will open in the last used directory or a default directory determined by the system. + /// + /// A specifying the initial directory for the dialog. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenFolderDialogBuilder InitialDirectory(string? initialDirectory) + { + _InitialDirectory = initialDirectory; + return this; + } + /// + /// Displays the dialog. If the user clicks the OK button, this method returns and the selected folder path is returned in the parameter. + /// + /// When this method returns, contains the selected folder path if the user clicked the OK button; otherwise, . + /// + /// , if the user clicked the OK button; + /// otherwise, . + /// + public bool Show([NotNullWhen(true)] out string? path) + { + OpenFolderDialog dialog = new() + { + InitialDirectory = _InitialDirectory ?? "" + }; + + if (ShowDialog(dialog, _Owner)) + { + path = dialog.FolderName; + return true; + } + else + { + path = null; + return false; + } + } } + /// - /// Displays a save file dialog and automatically adds an extension to the filename, if the user omits an extension. + /// Provides a fluent builder for configuring and displaying an open folder dialog that opens multiple folders. /// - /// A specifying the initial filename that can be changed by the user. - /// A specifying the extension to be added. - /// The description to be used. If , the class is used to retrieve the description. - /// - /// A representing the full path to the saved file and , if selection has been canceled by the user. - /// - public static string? Save(string? fileName, string? extension, string? extensionDescription) + public sealed class OpenMultipleFoldersDialogBuilder { - return Save(fileName, extension, extensionDescription, null); + private Window? _Owner; + private string? _InitialDirectory; + + internal OpenMultipleFoldersDialogBuilder() + { + } + + /// + /// Sets the owner of the dialog. + /// + /// A to use as the owner of the dialog, or to not specify an owner. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenMultipleFoldersDialogBuilder Owner(Window? owner) + { + _Owner = owner; + return this; + } + /// + /// Sets the initial directory for the dialog. If set to , the dialog will open in the last used directory or a default directory determined by the system. + /// + /// A specifying the initial directory for the dialog. + /// + /// A reference to this instance after the operation has completed. + /// + public OpenMultipleFoldersDialogBuilder InitialDirectory(string? initialDirectory) + { + _InitialDirectory = initialDirectory; + return this; + } + /// + /// Displays the dialog. If the user clicks the OK button, this method returns and the selected folder paths are returned in the parameter. + /// + /// When this method returns, contains the selected folder paths if the user clicked the OK button; otherwise, an empty array. + /// + /// , if the user clicked the OK button; + /// otherwise, . + /// + public bool Show(out string[] paths) + { + OpenFolderDialog dialog = new() + { + Multiselect = true, + InitialDirectory = _InitialDirectory ?? "" + }; + + if (ShowDialog(dialog, _Owner)) + { + paths = dialog.FolderNames; + return true; + } + else + { + paths = []; + return false; + } + } } + /// - /// Displays a save file dialog and automatically adds an extension to the filename, if the user omits an extension. + /// Provides a fluent builder for configuring and displaying an icon selection dialog. /// - /// A specifying the initial filename that can be changed by the user. - /// A specifying the extension to be added. - /// The description to be used. If , the class is used to retrieve the description. - /// A specifying the initial directory. - /// - /// A representing the full path to the saved file and , if selection has been canceled by the user. - /// - public static string? Save(string? fileName, string? extension, string? extensionDescription, string? initialDirectory) + public sealed class SelectIconDialogBuilder { - extension ??= Path.GetExtension(fileName).ToNullIfEmpty(); - extension = extension?.TrimStart('.'); + private Window? _Owner; + private string? _FileName; - SaveFileDialog dialog = new() + internal SelectIconDialogBuilder() { - FileName = fileName ?? "", - Filter = GetFilter([extension], extensionDescription), - DefaultExt = extension, - AddExtension = extension != null, - InitialDirectory = initialDirectory ?? "" - }; + } - if (dialog.ShowDialog() == true) + /// + /// Sets the owner of the dialog. + /// + /// A to use as the owner of the dialog, or to not specify an owner. + /// + /// A reference to this instance after the operation has completed. + /// + public SelectIconDialogBuilder Owner(Window? owner) { - string result = dialog.FileName; + _Owner = owner; + return this; + } + /// + /// Sets the initial filename of the icon selection dialog. + /// + /// The initial filename of the icon selection dialog. + /// + /// A reference to this instance after the operation has completed. + /// + public SelectIconDialogBuilder FileName(string? fileName) + { + _FileName = fileName; + return this; + } + /// + /// Displays the dialog. If the user clicks the OK button, this method returns , the selected file name is returned in the parameter, and the selected icon index is returned in the parameter. + /// + /// When this method returns, contains the selected file name if the user clicked the OK button; otherwise, . + /// When this method returns, contains the selected icon index if the user clicked the OK button; otherwise, 0. + /// + /// , if the user clicked the OK button; + /// otherwise, . + /// + public bool Show([NotNullWhen(true)] out string? fileName, out int index) + { + IconPickerDialog dialog = new() + { + FileName = _FileName + }; - if (extension != null && !result.EndsWith($".{extension}", StringComparison.OrdinalIgnoreCase)) + if (ShowDialog(dialog, _Owner) && dialog.FileName != null) + { + fileName = dialog.FileName; + index = dialog.IconIndex; + return true; + } + else { - result = $"{result.TrimEnd('.')}.{extension}"; + fileName = null; + index = 0; + return false; } + } + } - return result; + /// + /// Provides a fluent builder for configuring and displaying a save file dialog. + /// + public sealed class SaveFileDialogBuilder + { + private Window? _Owner; + private string? _FileName; + private readonly List _FileTypes; + private string? _InitialDirectory; + + internal SaveFileDialogBuilder() + { + _FileTypes = []; } - else + + /// + /// Sets the owner of the dialog. + /// + /// A to use as the owner of the dialog, or to not specify an owner. + /// + /// A reference to this instance after the operation has completed. + /// + public SaveFileDialogBuilder Owner(Window? owner) { - return null; + _Owner = owner; + return this; } - } + /// + /// Sets the initial filename of the save file dialog. + /// + /// The initial filename of the save file dialog. + /// + /// A reference to this instance after the operation has completed. + /// + public SaveFileDialogBuilder FileName(string? fileName) + { + _FileName = fileName; + return this; + } + /// + /// Specifies the file extensions that are allowed to be saved. + /// This method can be called multiple times to specify multiple sets of extensions to choose from. + /// If no extension is specified, the extension of the initial file name is used. + /// + /// The extensions that are allowed to be saved. + /// + /// A reference to this instance after the operation has completed. + /// + public SaveFileDialogBuilder FileType(params string[] extensions) + { + return FileType(extensions, null); + } + /// + /// Specifies the file extensions that are allowed to be saved. + /// This method can be called multiple times to specify multiple sets of extensions to choose from. + /// If no extension is specified, the extension of the initial file name is used. + /// + /// The extensions that are allowed to be saved. + /// The description to be used. If set to , the description is retrieved automatically from the shell. + /// + /// A reference to this instance after the operation has completed. + /// + public SaveFileDialogBuilder FileType(string[] extensions, string? description) + { + Check.ArgumentNull(extensions); + Check.ArgumentEx.ArrayElementsRequired(extensions); + Check.ArgumentEx.ArrayValuesNotNull(extensions); + Check.ArgumentEx.ArrayValuesNotStringEmptyOrWhiteSpace(extensions); - private static string GetFilter(string?[]? extensions, string? extensionsDescription) - { - if (extensions.IsNullOrEmpty()) + _FileTypes.Add(new(extensions.Select(extension => NormalizeExtension(extension)!).ToArray(), description)); + return this; + } + /// + /// Sets the initial directory for the dialog. If set to , the dialog will open in the last used directory or a default directory determined by the system. + /// + /// A specifying the initial directory for the dialog. + /// + /// A reference to this instance after the operation has completed. + /// + public SaveFileDialogBuilder InitialDirectory(string? initialDirectory) { - return (extensionsDescription ?? "All Files") + "|*.*"; + _InitialDirectory = initialDirectory; + return this; } - else if (extensions.Length == 1) + /// + /// Displays the dialog. If the user clicks the OK button, this method returns and the selected file name is returned in the parameter. + /// + /// When this method returns, contains the selected file name if the user clicked the OK button; otherwise, . + /// + /// , if the user clicked the OK button; + /// otherwise, . + /// + public bool Show([NotNullWhen(true)] out string? fileName) { - return (extensionsDescription ?? new FileExtensionInfo(extensions.First() ?? "").FriendlyDocName) + "|*." + extensions.First()?.ToLower().TrimStart('.'); + DialogFileType[] fileTypes = _FileTypes.Any() + ? _FileTypes.ToArray() + : NormalizeExtension(Path.GetExtension(_FileName)) is string fileNameExtension + ? [new([fileNameExtension], null)] + : []; + + SaveFileDialog dialog = new() + { + FileName = _FileName ?? "", + Filter = GetFilter(fileTypes), + InitialDirectory = _InitialDirectory ?? "" + }; + + if (ShowDialog(dialog, _Owner)) + { + string result = dialog.FileName; + string[] allExtensions = fileTypes.SelectMany(fileType => fileType.Extensions).ToArray(); + string? selectedExtension = dialog.FilterIndex > 0 && dialog.FilterIndex <= fileTypes.Length ? fileTypes[dialog.FilterIndex - 1].Extensions.First() : null; + + if (selectedExtension != null && + allExtensions.Any() && + allExtensions.None(extension => result.EndsWith($".{extension}", StringComparison.OrdinalIgnoreCase))) + { + result = $"{result.TrimEnd('.')}.{selectedExtension}"; + } + + fileName = result; + return true; + } + else + { + fileName = null; + return false; + } } - else + } + + private sealed class DialogFileType + { + public string[] Extensions { get; } + public string? Description { get; } + + public DialogFileType(string[] extensions, string? description) { - return (extensionsDescription ?? "Miscellaneous Files") + "|" + extensions.Select(extension => "*." + extension?.TrimStart('.')).AsString(";"); + Extensions = extensions; + Description = description; } } } \ No newline at end of file diff --git a/BytecodeApi.Wpf/Dialogs/IconPickerDialog.cs b/BytecodeApi.Wpf/Dialogs/IconPickerDialog.cs index cce3847..997a9e1 100644 --- a/BytecodeApi.Wpf/Dialogs/IconPickerDialog.cs +++ b/BytecodeApi.Wpf/Dialogs/IconPickerDialog.cs @@ -1,4 +1,4 @@ -using Microsoft.Win32; +using Microsoft.Win32; using System.Runtime.InteropServices; using System.Security; using System.Text; @@ -11,9 +11,9 @@ namespace BytecodeApi.Wpf.Dialogs; public sealed class IconPickerDialog : CommonDialog { /// - /// Gets a containing the full path of the selected file. + /// Gets or sets a containing the full path of the selected file. /// - public string? FileName { get; private set; } + public string? FileName { get; set; } /// /// Gets a value specifying the selected icon index. /// diff --git a/BytecodeApi/Internal/Check.cs b/BytecodeApi/Internal/Check.cs index d3f1582..504336d 100644 --- a/BytecodeApi/Internal/Check.cs +++ b/BytecodeApi/Internal/Check.cs @@ -142,7 +142,7 @@ public static void StringNotEmpty(string? parameter, [CallerArgumentExpression(n [DebuggerHidden, MethodImpl(MethodImplOptions.AggressiveInlining)] public static void StringNotEmptyOrWhiteSpace(string parameter, [CallerArgumentExpression(nameof(parameter))] string? parameterName = null) { - if (parameter == "" || parameter.Any(char.IsWhiteSpace)) + if (parameter != null && string.IsNullOrWhiteSpace(parameter)) { throw new ArgumentException(ExceptionMessages.Argument.StringNotEmptyOrWhiteSpace, parameterName); } @@ -188,6 +188,14 @@ public static void ArrayValuesNotStringEmpty(IEnumerable parameter, [Cal } } [DebuggerHidden, MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ArrayValuesNotStringEmptyOrWhiteSpace(IEnumerable parameter, [CallerArgumentExpression(nameof(parameter))] string? parameterName = null) + { + if (parameter.Any(itm => itm != null && string.IsNullOrWhiteSpace(itm))) + { + throw new ArgumentException(ExceptionMessages.Argument.ArrayValuesNotStringEmptyOrWhiteSpace, parameterName); + } + } + [DebuggerHidden, MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Handle(nint handle, [CallerArgumentExpression(nameof(handle))] string? parameterName = null) { if (handle is 0 or -1) diff --git a/BytecodeApi/Internal/ExceptionMessages.cs b/BytecodeApi/Internal/ExceptionMessages.cs index 8decf8a..08f8e2c 100644 --- a/BytecodeApi/Internal/ExceptionMessages.cs +++ b/BytecodeApi/Internal/ExceptionMessages.cs @@ -17,6 +17,7 @@ public static class Argument public const string EnumerableElementsRequired = "Sequence contains no elements."; public const string ArrayValuesNotNull = "Array must not contain null values."; public const string ArrayValuesNotStringEmpty = "Array must not contain empty strings."; + public const string ArrayValuesNotStringEmptyOrWhiteSpace = "Array must not contain empty or whitespace strings."; public const string InvalidHandle = "Invalid handle."; } public static class ArgumentOutOfRange From c1dacfef70b23bc4ee633591e0aecf1e6c6b45d4 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Fri, 12 Jun 2026 15:50:24 +0200 Subject: [PATCH 6/6] + BytecodeApi.Extensions.DateTimeOffsetExtensions --- BytecodeApi/Extensions/DateOnlyExtensions.cs | 22 +- BytecodeApi/Extensions/DateTimeExtensions.cs | 25 +- .../Extensions/DateTimeOffsetExtensions.cs | 298 ++++++++++++++++++ 3 files changed, 326 insertions(+), 19 deletions(-) create mode 100644 BytecodeApi/Extensions/DateTimeOffsetExtensions.cs diff --git a/BytecodeApi/Extensions/DateOnlyExtensions.cs b/BytecodeApi/Extensions/DateOnlyExtensions.cs index 24d1856..2b76b08 100644 --- a/BytecodeApi/Extensions/DateOnlyExtensions.cs +++ b/BytecodeApi/Extensions/DateOnlyExtensions.cs @@ -48,7 +48,7 @@ public static double GetTotalMonthsDifference(DateOnly a, DateOnly b) if (Math.Min(a.Day, b.GetDaysInMonth()) - 1 == b.Day) { // Full month with 1 day offset (e.g. 16.03. - 15.06.) - // This includes "end of month days" (e.g. 30.01. - 27.02. is two full months) + // This includes "end of month days" (e.g. 30.01. - 28.02. is two full months) difference = GetMonthsDifference(a, b); } else @@ -76,7 +76,7 @@ public static double GetTotalMonthsDifference(DateOnly a, DateOnly b) /// /// Calculates the age from a birthday. /// - /// A value representing the birhtday to calculate the age from. + /// A value representing the birthday to calculate the age from. /// /// An equivalent value representing an age, calculated from . /// @@ -87,7 +87,7 @@ public static int CalculateAgeFromBirthday(DateOnly birthday) /// /// Calculates the age from a birthday at a specified point in time. /// - /// A value representing the birhtday to calculate the age from. + /// A value representing the birthday to calculate the age from. /// A value representing the current date. This is usually . /// /// An equivalent value representing an age, calculated from and . @@ -227,6 +227,19 @@ public DateOnly AddMonths(double months) return dateOnly; } /// + /// Determines whether the specified is equal to this instance. The parameter specifies which fraction is considered during comparison. + /// + /// A to compare with this . + /// The specifying, which fraction is considered during comparison. + /// + /// , if the specified is equal to this instance; + /// otherwise, . + /// + public bool Equals(DateOnly other, DateOnlyPart part) + { + return dateOnly.GetPart(part) == other.GetPart(part); + } + /// /// Compares the value of this instance to a specified value and returns an integer that indicates whether this instance is earlier than, the same as, or later than the specified value. The parameter specifies which fraction is considered during comparison. /// /// A to compare with this . @@ -292,13 +305,14 @@ public DateOnly GetFirstDayOfWeek(DayOfWeek firstDayOfWeek) { dateOnly = dateOnly.AddDays(-1); } + return dateOnly; } /// /// Returns a from this value. /// /// - /// The converted value. + /// The converted value. /// public DateTime ToDateTime() { diff --git a/BytecodeApi/Extensions/DateTimeExtensions.cs b/BytecodeApi/Extensions/DateTimeExtensions.cs index 60de7cd..2f99580 100644 --- a/BytecodeApi/Extensions/DateTimeExtensions.cs +++ b/BytecodeApi/Extensions/DateTimeExtensions.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; namespace BytecodeApi.Extensions; @@ -63,7 +63,7 @@ public static DateTime FromUnixTimeStamp(int seconds) return FromUnixTimeStamp(seconds, DateTimeKind.Unspecified); } /// - /// Converts a value representing a unix time stamp to a object, using the specified . + /// Converts a value representing a unix time stamp to a object. /// /// The seconds starting from 01.01.1970 00:00:00. /// The to be used for creation of the object. @@ -75,7 +75,7 @@ public static DateTime FromUnixTimeStamp(int seconds, DateTimeKind kind) return new DateTime(1970, 1, 1, 0, 0, 0, kind).AddSeconds(seconds); } /// - /// Converts a value to its equivalent unix time stamp represented as a value, using the specified . If is out of bounds of the unix epoch, is returned. + /// Converts a value to its equivalent unix time stamp represented as a value. If is out of bounds of the unix epoch, is returned. /// /// The object which is converted to its equivalent unix time stamp representation. /// @@ -87,7 +87,7 @@ public static DateTime FromUnixTimeStamp(int seconds, DateTimeKind kind) return ToUnixTimeStamp(dateTime, DateTimeKind.Unspecified); } /// - /// Converts a value to its equivalent unix time stamp represented as a value, using the kind. If is out of bounds of the unix epoch, is returned. + /// Converts a value to its equivalent unix time stamp represented as a value. If is out of bounds of the unix epoch, is returned. /// /// The object which is converted to its equivalent unix time stamp representation. /// The to be used for conversion of the object. @@ -98,36 +98,30 @@ public static DateTime FromUnixTimeStamp(int seconds, DateTimeKind kind) public static int? ToUnixTimeStamp(DateTime dateTime, DateTimeKind kind) { double seconds = (dateTime - new DateTime(1970, 1, 1, 0, 0, 0, kind)).TotalSeconds; - return seconds is > 0 and <= int.MaxValue ? (int)seconds : null; + return seconds is >= 0 and <= int.MaxValue ? (int)seconds : null; } /// /// Calculates the age from a birthday. /// - /// A value representing the birhtday to calculate the age from. + /// A value representing the birthday to calculate the age from. /// /// An equivalent value representing an age, calculated from . /// public static int CalculateAgeFromBirthday(DateTime birthday) { - return CalculateAgeFromBirthday(birthday, DateTime.Now); + return DateOnly.CalculateAgeFromBirthday(birthday.ToDateOnly()); } /// /// Calculates the age from a birthday at a specified point in time. /// - /// A value representing the birhtday to calculate the age from. + /// A value representing the birthday to calculate the age from. /// A value representing the current time stamp. This is usually . /// /// An equivalent value representing an age, calculated from and . /// public static int CalculateAgeFromBirthday(DateTime birthday, DateTime now) { - int age = now.Year - birthday.Year; - if (now < birthday.AddYears(age)) - { - age--; - } - - return age; + return DateOnly.CalculateAgeFromBirthday(birthday.ToDateOnly(), now.ToDateOnly()); } } @@ -335,6 +329,7 @@ public DateTime GetFirstDayOfWeek(DayOfWeek firstDayOfWeek) { dateTime = dateTime.AddDays(-1); } + return dateTime.Date; } /// diff --git a/BytecodeApi/Extensions/DateTimeOffsetExtensions.cs b/BytecodeApi/Extensions/DateTimeOffsetExtensions.cs new file mode 100644 index 0000000..cd73250 --- /dev/null +++ b/BytecodeApi/Extensions/DateTimeOffsetExtensions.cs @@ -0,0 +1,298 @@ +using System.Globalization; + +namespace BytecodeApi.Extensions; + +/// +/// Provides a set of methods for interaction with objects. +/// +public static class DateTimeOffsetExtensions +{ + extension(DateTimeOffset) + { + /// + /// Computes the number of months between two values. + /// + /// The first value. + /// The second value. + /// + /// The number of months between two values. + /// + public static int GetMonthsDifference(DateTimeOffset a, DateTimeOffset b) + { + return (b.Year - a.Year) * 12 + b.Month - a.Month; + } + /// + /// Computes the number of months between two values, including fractional months. + /// + /// The first value. + /// The second value. + /// + /// The number of months between two values, including fractional months. + /// + public static double GetTotalMonthsDifference(DateTimeOffset a, DateTimeOffset b) + { + return DateOnly.GetTotalMonthsDifference(a.ToDateOnly(), b.ToDateOnly()); + } + /// + /// Converts a value representing a unix time stamp to a object. + /// + /// The seconds starting from 01.01.1970 00:00:00. + /// + /// A new object whose value is the sum of 01.01.1970 00:00:00 and . + /// + public static DateTimeOffset FromUnixTimeStamp(int seconds) + { + return new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(seconds); + } + /// + /// Converts a value to its equivalent unix time stamp represented as a value. If is out of bounds of the unix epoch, is returned. + /// + /// The object which is converted to its equivalent unix time stamp representation. + /// + /// If is in bounds of the unix epoch, the amount of seconds between 01.01.1970 00:00:00 and ; + /// otherwise, . + /// + public static int? ToUnixTimeStamp(DateTimeOffset dateTimeOffset) + { + double seconds = (dateTimeOffset - new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalSeconds; + return seconds is >= 0 and <= int.MaxValue ? (int)seconds : null; + } + /// + /// Calculates the age from a birthday. + /// + /// A value representing the birthday to calculate the age from. + /// + /// An equivalent value representing an age, calculated from . + /// + public static int CalculateAgeFromBirthday(DateTimeOffset birthday) + { + return DateOnly.CalculateAgeFromBirthday(birthday.ToDateOnly()); + } + /// + /// Calculates the age from a birthday at a specified point in time. + /// + /// A value representing the birthday to calculate the age from. + /// A value representing the current time stamp. This is usually . + /// + /// An equivalent value representing an age, calculated from and . + /// + public static int CalculateAgeFromBirthday(DateTimeOffset birthday, DateTimeOffset now) + { + return DateOnly.CalculateAgeFromBirthday(birthday.ToDateOnly(), now.ToDateOnly()); + } + } + + extension(DateTimeOffset dateTimeOffset) + { + /// + /// Converts the value of this to its equivalent representation using a specified format and the invariant culture. + /// + /// A value specifying the format that is used to convert this . + /// + /// The equivalent representation of this . + /// + public string ToStringInvariant(string format) + { + return dateTimeOffset.ToString(format, CultureInfo.InvariantCulture); + } + /// + /// Returns , if this object is (), otherwise its original value. + /// + /// + /// , if this object is (); + /// otherwise, its original value. + /// + public DateTimeOffset? ToNullIfDefault() + { + return dateTimeOffset == default ? null : dateTimeOffset; + } + /// + /// Returns a new that adds the specified number of business days to this value. Business days exclude Saturday and Sunday. If is positive, days are added, otherwise days are subtracted. + /// Example 1: Friday + 2 business days = Tuesday + /// Example 2: Monday - 2 business days = Thursday + /// + /// A value specifying the business days to be added to this object. + /// + /// A new object whose value is the sum of this value and the specified business days. + /// + public DateTimeOffset AddBusinessDays(int days) + { + if (days != 0) + { + int sign = Math.Sign(days); + days = Math.Abs(days); + + for (int i = 0; i < days; i++) + { + if (sign > 0) + { + dateTimeOffset = dateTimeOffset.DayOfWeek switch + { + DayOfWeek.Friday => dateTimeOffset.AddDays(3), + DayOfWeek.Saturday => dateTimeOffset.AddDays(2), + _ => dateTimeOffset.AddDays(1) + }; + } + else + { + dateTimeOffset = dateTimeOffset.DayOfWeek switch + { + DayOfWeek.Sunday => dateTimeOffset.AddDays(-2), + DayOfWeek.Monday => dateTimeOffset.AddDays(-3), + _ => dateTimeOffset.AddDays(-1) + }; + } + } + } + + return dateTimeOffset; + } + /// + /// Computes the total count of business days between two instances. Business days exclude Saturday and Sunday. The time fraction is ignored and the returned value is inclusive. + /// Example 1: Friday through Tuesday = 3 business days + /// Example 2: Saturday through Sunday = 0 business days + /// + /// The value to compare to this . can be either less or greater than this value. + /// + /// A value representing the total count of business days between two instances. + /// + public int GetTotalBusinessDays(DateTimeOffset value) + { + return dateTimeOffset.ToDateOnly().GetTotalBusinessDays(value.ToDateOnly()); + } + /// + /// Gets the number of days in the month of the specified . + /// + /// + /// The number of days in the month of the specified . + /// + public int GetDaysInMonth() + { + return DateTime.DaysInMonth(dateTimeOffset.Year, dateTimeOffset.Month); + } + /// + /// Returns a new that adds the specified number of months, including fractions of a month. + /// + /// A number of months. This number can be negative or positive. If the number is fractional, the fraction is multiplied by the number of days in the month after the whole months were added. + /// + /// A new whose value is the sum of the original value and . + /// + public DateTimeOffset AddMonths(double months) + { + dateTimeOffset = dateTimeOffset.AddMonths((int)months); + + months %= 1; + if (months != 0) + { + dateTimeOffset = dateTimeOffset.AddDays((int)Math.Round(months * dateTimeOffset.GetDaysInMonth())); + } + + return dateTimeOffset; + } + /// + /// Determines whether the specified is equal to this instance. The parameter specifies which fraction is considered during comparison. + /// + /// A to compare with this . + /// The specifying, which fraction is considered during comparison. + /// + /// , if the specified is equal to this instance; + /// otherwise, . + /// + public bool Equals(DateTimeOffset other, DateTimePart part) + { + return dateTimeOffset.GetPart(part).DateTime == other.GetPart(part).DateTime; + } + /// + /// Compares the value of this instance to a specified value and returns an integer that indicates whether this instance is earlier than, the same as, or later than the specified value. The parameter specifies which fraction is considered during comparison. + /// + /// A to compare with this . + /// The specifying, which fraction is considered during comparison. + /// + /// A value that indicates the relative order of the objects being compared considering only the specified . + /// + public int CompareTo(DateTimeOffset other, DateTimePart part) + { + return dateTimeOffset.GetPart(part).DateTime.CompareTo(other.GetPart(part).DateTime); + } + /// + /// Returns a new that represents a fraction of this value specified by the parameter. + /// + /// The specifying, which fraction of this is returned. + /// + /// A new that represents a fraction of this value specified by the parameter. + /// + public DateTimeOffset GetPart(DateTimePart part) + { + return part switch + { + DateTimePart.Full => dateTimeOffset, + DateTimePart.DateTimeWithSeconds => new(dateTimeOffset.Year, dateTimeOffset.Month, dateTimeOffset.Day, dateTimeOffset.Hour, dateTimeOffset.Minute, dateTimeOffset.Second, dateTimeOffset.Offset), + DateTimePart.DateTime => new(dateTimeOffset.Year, dateTimeOffset.Month, dateTimeOffset.Day, dateTimeOffset.Hour, dateTimeOffset.Minute, 0, dateTimeOffset.Offset), + DateTimePart.Date => new(dateTimeOffset.Year, dateTimeOffset.Month, dateTimeOffset.Day, 0, 0, 0, dateTimeOffset.Offset), + DateTimePart.YearMonth => new(dateTimeOffset.Year, dateTimeOffset.Month, 1, 0, 0, 0, dateTimeOffset.Offset), + DateTimePart.YearQuarter => new(dateTimeOffset.Year, (dateTimeOffset.Month - 1) / 3 * 3 + 1, 1, 0, 0, 0, dateTimeOffset.Offset), + DateTimePart.Year => new(dateTimeOffset.Year, 1, 1, 0, 0, 0, dateTimeOffset.Offset), + _ => throw Throw.InvalidEnumArgument(nameof(part), part) + }; + } + /// + /// Returns a new representing the first day of the week according to the current culture. + /// + /// + /// A new object representing the first day of the week according to the current culture. + /// + public DateTimeOffset GetFirstDayOfWeek() + { + return dateTimeOffset.GetFirstDayOfWeek(CultureInfo.CurrentCulture); + } + /// + /// Returns a new representing the first day of the week using specified culture-specific calendar rules. + /// + /// An object that supplies culture-specific calendar rules. + /// + /// A new object representing the first day of the week according to . + /// + public DateTimeOffset GetFirstDayOfWeek(CultureInfo culture) + { + Check.ArgumentNull(culture); + + return dateTimeOffset.GetFirstDayOfWeek(culture.DateTimeFormat.FirstDayOfWeek); + } + /// + /// Returns a new representing the first day of the week, according to the parameter. + /// + /// The first day of week. + /// + /// A new object representing the first day of the week, according to the parameter. + /// + public DateTimeOffset GetFirstDayOfWeek(DayOfWeek firstDayOfWeek) + { + while (dateTimeOffset.DayOfWeek != firstDayOfWeek) + { + dateTimeOffset = dateTimeOffset.AddDays(-1); + } + + return dateTimeOffset.GetPart(DateTimePart.Date); + } + /// + /// Returns a from this value. + /// + /// + /// The converted value. + /// + public DateOnly ToDateOnly() + { + return DateOnly.FromDateTime(dateTimeOffset.DateTime); + } + /// + /// Returns a from this value. + /// + /// + /// The converted value. + /// + public TimeOnly ToTimeOnly() + { + return TimeOnly.FromDateTime(dateTimeOffset.DateTime); + } + } +} \ No newline at end of file