Private/NC-Hlp.ModuleUtils.ps1
|
#Requires -Version 5.0 using namespace System.Management.Automation # Nebula.Core: (Private) Module helpers ============================================================================================================= function Format-OutputString { <# .SYNOPSIS Truncates a string to a maximum length. .DESCRIPTION Returns the original value when shorter than the specified length; otherwise appends ellipsis. .PARAMETER Value String to trim. .PARAMETER MaxLength Maximum allowed length (defaults to module configuration). #> [CmdletBinding()] param( [string]$Value, [ValidateRange(3, 260)] [int]$MaxLength = $NCVars.MaxFieldLength ) if ([string]::IsNullOrEmpty($Value)) { return $Value } if ($Value.Length -le $MaxLength) { return $Value } $length = [Math]::Max(3, $MaxLength) return $Value.Substring(0, $length - 3) + '...' } function Format-NCDateTime { <# .SYNOPSIS Formats a date value using Nebula.Core conventions. .DESCRIPTION Converts a date-like value to a string using the configured Nebula date format. Returns the original value when it cannot be parsed as a date. .PARAMETER Value Date value to format. .PARAMETER Format Target string format. Defaults to the configured full date/time format. .PARAMETER AsLocalTime Convert the value to local time before formatting. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Value, [string]$Format = $NCVars.DateTimeString_Full, [switch]$AsLocalTime ) if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { return $null } $dateTimeOffset = $null $parsed = $false if ($Value -is [datetimeoffset]) { $dateTimeOffset = [datetimeoffset]$Value $parsed = $true } elseif ($Value -is [datetime]) { $dateTimeOffset = [datetimeoffset]::new([datetime]$Value) $parsed = $true } else { $text = [string]$Value $styles = [System.Globalization.DateTimeStyles]::AllowWhiteSpaces -bor [System.Globalization.DateTimeStyles]::RoundtripKind $formats = @( 'o', 'O', 's', 'yyyy-MM-ddTHH:mm:ssK', 'yyyy-MM-ddTHH:mm:ss.FFFFFFFK', 'yyyy-MM-dd HH:mm:ssK', 'yyyy-MM-ddTHH:mm:ss', 'yyyy-MM-dd HH:mm:ss' ) foreach ($fmt in $formats) { if ([datetimeoffset]::TryParseExact($text, $fmt, [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$dateTimeOffset)) { $parsed = $true break } } if (-not $parsed) { return [string]$Value } } $targetTimeZoneId = $NCVars.DateTimeTimeZone $timeZoneInfo = $null if (-not [string]::IsNullOrWhiteSpace([string]$targetTimeZoneId)) { $timeZoneInfo = Get-NCDateTimeZoneInfo -TimeZoneId $targetTimeZoneId } # A configured time zone that can't be resolved is ignored, as if none were set if ($timeZoneInfo) { $dateTimeOffset = [System.TimeZoneInfo]::ConvertTime($dateTimeOffset, $timeZoneInfo) } elseif ($AsLocalTime) { $dateTimeOffset = $dateTimeOffset.ToLocalTime() } return $dateTimeOffset.ToString($Format) } function Get-NCDateTimeZoneInfo { <# .SYNOPSIS Resolves a time zone ID for Nebula.Core date formatting. .DESCRIPTION Tries the provided ID first, then a small alias map for common cross-platform zone names. .PARAMETER TimeZoneId Time zone identifier from configuration. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$TimeZoneId ) if ([string]::IsNullOrWhiteSpace($TimeZoneId)) { return $null } if ($script:NCTimeZoneCache -and $script:NCTimeZoneCache.TimeZoneId -eq $TimeZoneId) { return $script:NCTimeZoneCache.TimeZoneInfo } $candidateIds = [System.Collections.Generic.List[string]]::new() $candidateIds.Add($TimeZoneId.Trim()) $aliasMap = @{ 'Europe/Rome' = 'W. Europe Standard Time' } if ($aliasMap.ContainsKey($TimeZoneId.Trim())) { $candidateIds.Add($aliasMap[$TimeZoneId.Trim()]) } foreach ($candidateId in $candidateIds) { try { $timeZoneInfo = [System.TimeZoneInfo]::FindSystemTimeZoneById($candidateId) $script:NCTimeZoneCache = [pscustomobject]@{ TimeZoneId = $TimeZoneId TimeZoneInfo = $timeZoneInfo } return $timeZoneInfo } catch { continue } } Write-NCMessage "Unable to resolve time zone '$TimeZoneId'. Ignoring DateTimeTimeZone: dates use local time where requested, otherwise their original offset." -Level WARNING $script:NCTimeZoneCache = [pscustomobject]@{ TimeZoneId = $TimeZoneId TimeZoneInfo = $null } return $null } function Get-NormalizedText { <# .SYNOPSIS Returns a lower-cased, trimmed string from a value or object. .DESCRIPTION Accepts plain strings, deserialized Exchange objects, and other objects with useful identity properties. Falls back to the object's string representation and returns $null for blank values. .PARAMETER Value Value to normalize. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Value ) $text = $null if ($Value -is [string]) { $text = $Value } elseif ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { $items = foreach ($item in $Value) { if (-not [string]::IsNullOrWhiteSpace([string]$item)) { [string]$item } } if ($items) { $text = $items -join ', ' } } else { foreach ($propertyName in @( 'PrimarySmtpAddress', 'UserPrincipalName', 'WindowsEmailAddress', 'SmtpAddress', 'EmailAddress', 'Value', 'Name', 'DisplayName', 'Identity', 'RawIdentity' )) { if ($Value.PSObject.Properties.Match($propertyName).Count -gt 0) { $candidate = $Value.$propertyName if (-not [string]::IsNullOrWhiteSpace([string]$candidate)) { $text = [string]$candidate break } } } } if (-not $text) { $text = [string]$Value } if ([string]::IsNullOrWhiteSpace($text)) { return $null } return $text.Trim().ToLowerInvariant() } function Invoke-NCRetry { <# .SYNOPSIS Executes a scriptblock with retry logic. .DESCRIPTION Runs the provided block up to MaxAttempts, invoking OnError between retries. Throws the last error once all attempts are exhausted. .PARAMETER Action Script block to execute. .PARAMETER MaxAttempts Maximum number of attempts before throwing (default 3). .PARAMETER DelaySeconds Pause between attempts (default 5 seconds). .PARAMETER OperationDescription Friendly description used in log messages. .PARAMETER OnError Optional callback invoked after each failure. #> [CmdletBinding()] param( [Parameter(Mandatory)] [scriptblock]$Action, [ValidateRange(1, [int]::MaxValue)] [int]$MaxAttempts = 3, [ValidateRange(0, [int]::MaxValue)] [int]$DelaySeconds = 5, [string]$OperationDescription = 'operation', [scriptblock]$OnError ) $attempt = 0 while ($attempt -lt $MaxAttempts) { $attempt++ try { return & $Action } catch { if ($OnError) { $safeAttempt = if ($attempt -gt 0) { $attempt } else { 1 } $safeMax = if ($MaxAttempts -gt 0) { $MaxAttempts } else { 1 } & $OnError $safeAttempt $safeMax $_ } else { Write-NCMessage "Operation '$OperationDescription' failed (attempt $attempt of $MaxAttempts). $($_.Exception.Message)" -Level ERROR } if ($attempt -ge $MaxAttempts) { throw } if ($DelaySeconds -gt 0) { Start-Sleep -Seconds $DelaySeconds } } } } function New-File { <# .SYNOPSIS Generates a non-colliding file path. .DESCRIPTION Given a desired path, appends _N before the extension until an unused name is found. .PARAMETER Path Desired output file path. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path ) $baseName = [System.IO.Path]::GetFileNameWithoutExtension($Path) $extension = [System.IO.Path]::GetExtension($Path) $directory = [System.IO.Path]::GetDirectoryName($Path) if (-not $directory) { $directory = (Get-Location).ProviderPath } $candidate = Join-Path -Path $directory -ChildPath ($baseName + $extension) $count = 1 while (Test-Path -LiteralPath $candidate) { $fileName = "{0}_{1}{2}" -f $baseName, $count, $extension $candidate = Join-Path -Path $directory -ChildPath $fileName $count++ } return $candidate } # PowerShell releases where Out-GridView never returns and no window appears # (https://github.com/PowerShell/PowerShell/issues/27994). $script:NCGridViewBrokenVersions = @('7.6.6') function Test-NCGridViewSupport { <# .SYNOPSIS Checks whether Out-GridView can be used in the current PowerShell session. .DESCRIPTION Returns $false on PowerShell releases where Out-GridView is known to hang the session (see $script:NCGridViewBrokenVersions), $true otherwise. #> [CmdletBinding()] [OutputType([bool])] param() if ($PSVersionTable.PSEdition -ne 'Core') { return $true } $version = $PSVersionTable.PSVersion $key = '{0}.{1}.{2}' -f $version.Major, $version.Minor, $version.Patch return -not ($script:NCGridViewBrokenVersions -contains $key) } function Out-NCGridView { <# .SYNOPSIS Sends objects to Out-GridView, falling back to the console where the grid is broken. .DESCRIPTION Wraps Out-GridView for every -GridView switch in the module. On PowerShell releases where Out-GridView hangs the session, writes a warning and emits the input objects to the pipeline instead. With -PassThru (interactive selection), the fallback selects nothing, so callers that act on the selection (release, delete) do not process every row by mistake. .PARAMETER InputObject Objects to display. .PARAMETER Title Grid window title. .PARAMETER PassThru Return the rows selected in the grid. #> [CmdletBinding()] param( [Parameter(ValueFromPipeline = $true)] [object]$InputObject, [string]$Title, [switch]$PassThru ) begin { $items = [System.Collections.Generic.List[object]]::new() } process { if ($null -ne $InputObject) { $items.Add($InputObject) | Out-Null } } end { if (Test-NCGridViewSupport) { $items | Out-GridView -Title $Title -PassThru:$PassThru return } $psVersion = $PSVersionTable.PSVersion.ToString() if ($PassThru.IsPresent) { Write-NCMessage "Out-GridView hangs on PowerShell $psVersion (PowerShell/PowerShell#27994), so rows can't be selected interactively: nothing was selected. Use Windows PowerShell 5.1 or another PowerShell 7 release for -GridView." -Level WARNING return } Write-NCMessage "Out-GridView hangs on PowerShell $psVersion (PowerShell/PowerShell#27994): showing results in the console instead. Use Windows PowerShell 5.1 or another PowerShell 7 release for -GridView." -Level WARNING $items } } function Restore-ProgressAndInfoPreferences { <# .SYNOPSIS Restores Information/Progress preference variables. .DESCRIPTION Reverts preference variables previously captured by Set-ProgressAndInfoPreferences. No-ops if nothing was captured. #> [CmdletBinding()] param() if (-not $script:PreferencesCaptured) { return } if ($null -ne $script:PreviousInformationPreference) { Set-Variable -Name InformationPreference -Value $script:PreviousInformationPreference -Scope Global } if ($null -ne $script:PreviousProgressPreference) { Set-Variable -Name ProgressPreference -Value $script:PreviousProgressPreference -Scope Global } $script:PreferencesCaptured = $false $script:PreviousInformationPreference = $null $script:PreviousProgressPreference = $null } function Set-ProgressAndInfoPreferences { <# .SYNOPSIS Forces Information/Progress preference variables to Continue. .DESCRIPTION Saves current preference values (once per session) and sets global InformationPreference and ProgressPreference to Continue for verbose output. #> [CmdletBinding()] param() if (-not $script:PreferencesCaptured) { $script:PreviousInformationPreference = $InformationPreference $script:PreviousProgressPreference = $ProgressPreference $script:PreferencesCaptured = $true } Set-Variable -Name InformationPreference -Value Continue -Scope Global Set-Variable -Name ProgressPreference -Value Continue -Scope Global } function Get-NCProgressPercent { <# .SYNOPSIS Calculates a percentage for progress reporting. .DESCRIPTION Returns a rounded percentage for the current work item against a total. The total is clamped to at least 1 to avoid divide-by-zero errors. .PARAMETER Current Current work item count. .PARAMETER Total Total number of work items. #> [CmdletBinding()] param( [Parameter(Mandatory)] [double]$Current, [Parameter(Mandatory)] [double]$Total ) $safeTotal = [Math]::Max($Total, 1) return [Math]::Round(($Current / $safeTotal) * 100, 2) } function Show-Table { <# .SYNOPSIS Outputs a table of rows. .DESCRIPTION Outputs a table of rows with a title. .PARAMETER Rows Table rows. .PARAMETER AsTable Output as a table. #> [CmdletBinding()] param( [array]$Rows, [switch]$AsTable ) if (-not $Rows -or $Rows.Count -eq 0) { Write-NCMessage "(none)" -Level INFO return } if ($AsTable) { $Rows | Format-Table -AutoSize } else { $Rows | Format-List } } function Test-Folder { <# .SYNOPSIS Normalizes and validates a folder path. .DESCRIPTION Returns the current directory when input is blank, trims trailing separators, resolves relative paths against the current location, and throws if the path is invalid. .PARAMETER Path Folder path to validate (optional). #> [CmdletBinding()] param( [string]$Path ) if ([string]::IsNullOrWhiteSpace($Path)) { return (Get-Location).ProviderPath } $normalized = $Path.TrimEnd('\') # Resolve existing paths directly if (Test-Path -LiteralPath $normalized) { return (Resolve-Path -LiteralPath $normalized).ProviderPath } # Build full path for non-existing targets (supports relative paths) $basePath = if ([IO.Path]::IsPathRooted($normalized)) { '' } else { (Get-Location).ProviderPath } $candidate = if ($basePath) { Join-Path -Path $basePath -ChildPath $normalized } else { $normalized } try { return [System.IO.Path]::GetFullPath($candidate) } catch { throw "Invalid folder path '$Path'. $($_.Exception.Message)" } } |