Src/Private/SharedUtilsFunctions.ps1

function ConvertTo-TextYN {
    <#
    .SYNOPSIS
        Used by As Built Report to convert true or false automatically to Yes or No.
    .DESCRIPTION
 
    .NOTES
        Version: 0.3.0
        Author: LEE DAILEY
 
    .EXAMPLE
 
    .LINK
 
    #>

    [CmdletBinding()]
    [OutputType([String])]
    param (
        [Parameter (
            Position = 0,
            Mandatory)]
        [AllowEmptyString()]
        [string] $TEXT
    )

    switch ($TEXT) {
        "" { "--"; break }
        " " { "--"; break }
        $Null { "--"; break }
        "True" { "Yes"; break }
        "False" { "No"; break }
        default { $TEXT }
    }
} # end

function ConvertTo-FileSizeString {
    <#
    .SYNOPSIS
    Used by As Built Report to convert bytes automatically to GB or TB based on size.
    .DESCRIPTION
    .NOTES
        Version: 0.1.0
        Author: Jonathan Colon
    .EXAMPLE
    .LINK
    #>

    [CmdletBinding()]
    [OutputType([String])]
    param
    (
        [Parameter (
            Position = 0,
            Mandatory)]
        [int64]
        $Size
    )

    $Unit = switch ($Size) {
        { $Size -gt 1PB } { 'PB' ; break }
        { $Size -gt 1TB } { 'TB' ; break }
        { $Size -gt 1GB } { 'GB' ; break }
        { $Size -gt 1Mb } { 'MB' ; break }
        Default { 'KB' }
    }
    return "$([math]::Round(($Size / $("1" + $Unit)), 0)) $Unit"
} # end

function ConvertTo-EmptyToFiller {
    <#
    .SYNOPSIS
        Used by As Built Report to convert empty culumns to "--".
    .DESCRIPTION
    .NOTES
        Version: 0.5.0
        Author: Jonathan Colon
    .EXAMPLE
    .LINK
    #>

    [CmdletBinding()]
    [OutputType([String])]
    param
    (
        [Parameter (
            Position = 0,
            Mandatory)]
        [AllowEmptyString()]
        [string]$TEXT
    )

    switch ([string]::IsNullOrEmpty($TEXT)) {
        $true { "--"; break }
        default { $TEXT }
    }
}

function Convert-IpAddressToMaskLength {
    <#
    .SYNOPSIS
        Used by As Built Report to convert subnet mask to dotted notation.
    .DESCRIPTION
 
    .NOTES
        Version: 0.4.0
        Author: Ronald Rink
 
    .EXAMPLE
 
    .LINK
 
    #>

    [CmdletBinding()]
    [OutputType([String])]
    param
    (
        [Parameter (
            Position = 0,
            Mandatory)]
        [string]
        $SubnetMask
    )

    [IPAddress] $MASK = $SubnetMask
    $octets = $MASK.IPAddressToString.Split('.')
    foreach ($octet in $octets) {
        while (0 -ne $octet) {
            $octet = ($octet -shl 1) -band [byte]::MaxValue
            $result++;
        }
    }
    return $result;
}

function ConvertTo-ADObjectName {
    <#
    .SYNOPSIS
        Used by As Built Report to translate Active Directory DN to Name.
    .DESCRIPTION
 
    .NOTES
        Version: 0.4.0
        Author: Jonathan Colon
 
    .EXAMPLE
 
    .LINK
 
    #>

    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        $DN,
        $Session
    )
    $ADObject = @()
    foreach ($Object in $DN) {
        $ADObject += Invoke-Command -Session $Session { Get-ADObject $using:Object | Select-Object -ExpandProperty Name }
    }
    return $ADObject;
}# end

function Get-LocalGroupMembership {
    <#
    .SYNOPSIS
        Recursively list all members of a specified Local group.
 
    .DESCRIPTION
        Recursively list all members of a specified Local group. This can be run against a local or
        remote system or systems. Recursion is unlimited unless specified by the -Depth parameter.
 
        Alias: glgm
 
    .NOTES
        Version: 0.5.4
        Author: Boe Prox (Updated by Graham Flynn (26/07/2024)
        Changes: Updated to ouput PrincipalSource and ObjectClass so output is similar to Microsoft's Get-LocalGroupMember for compatibility
 
    .PARAMETER Computername
        Local or remote computer/s to perform the query against.
        Default value is the local system.
 
    .PARAMETER Group
        Name of the group to query on a system for all members.
        Default value is 'Administrators'
 
    .PARAMETER Depth
        Limit the recursive depth of a query.
        Default value is 2147483647.
 
    .PARAMETER Throttle
        Number of concurrently running jobs to run at a time
        Default value is 10
 
    .EXAMPLE
        Get-LocalGroupMembership
 
        Name ParentGroup isGroup ObjectClass PrincipalSource Computername Depth
        ---- ----------- ------- ---- ------------ -----
        Administrator Administrators False User Local DC1 1
        boe Administrators False User Domain DC1 1
        testuser Administrators False User Local DC1 1
        bob Administrators False User Domain DC1 1
        proxb Administrators False User Domain DC1 1
        Enterprise Admins Administrators True Group Domain DC1 1
        Sysops Admins Enterprise Admins True Group Domain DC1 2
        Domain Admins Enterprise Admins True Group Domain DC1 2
        Administrator Enterprise Admins False User Domain DC1 2
        Domain Admins Administrators True Group Domain DC1 1
        proxb Domain Admins False User Domain DC1 2
        Administrator Domain Admins False User Domain DC1 2
        Sysops Admins Administrators True Group Domain DC1 1
        Org Admins Sysops Admins True Group Domain DC1 2
        Enterprise Admins Sysops Admins True Group Domain DC1 2
 
        Description
        -----------
        Gets all of the members of the 'Administrators' group on the local system.
 
    .EXAMPLE
        Get-LocalGroupMembership -Group 'Administrators' -Depth 1
 
        Name ParentGroup isGroup ObjectClass PrincipalSource Computername Depth
        ---- ----------- ------- ---- ------------ -----
        Administrator Administrators False User Local DC1 1
        boe Administrators False User Domain DC1 1
        testuser Administrators False User Local DC1 1
        bob Administrators False User Domain DC1 1
        proxb Administrators False User Domain DC1 1
        Enterprise Admins Administrators True Group Domain DC1 1
        Domain Admins Administrators True Group Domain DC1 1
        Sysops Admins Administrators True Group Domain DC1 1
 
        Description
        -----------
        Gets the members of 'Administrators' with only 1 level of recursion.
 
    .LINK
        Original Script: https://github.com/proxb/PowerShell_Scripts/blob/master/Get-LocalGroupMembership.ps1
 
    #>

    [cmdletbinding()]
    param (
        [parameter(ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
        [Alias('CN', '__Server', 'Computer', 'IPAddress')]
        [string[]]$Computername = $env:COMPUTERNAME,
        [parameter()]
        [string]$Group = "Administrators",
        [parameter()]
        [int]$Depth = ([int]::MaxValue),
        [parameter()]
        [Alias("MaxJobs")]
        [int]$Throttle = 10
    )
    begin {
        $PSBoundParameters.GetEnumerator() | ForEach-Object {
            Write-Verbose $_
        }
        # region Extra Configurations
        Write-Verbose ("Depth: {0}" -f $Depth)
        # endregion Extra Configurations
        # Define hash table for Get-RunspaceData function
        $runspacehash = @{}
        # Function to perform runspace job cleanup
        function Get-RunspaceData {
            [cmdletbinding()]
            param(
                [switch]$Wait
            )
            do {
                $more = $false
                foreach ($runspace in $runspaces) {
                    if ($runspace.Runspace.isCompleted) {
                        $runspace.powershell.EndInvoke($runspace.Runspace)
                        $runspace.powershell.dispose()
                        $runspace.Runspace = $null
                        $runspace.powershell = $null
                    } elseif ($runspace.Runspace -ne $null) {
                        $more = $true
                    }
                }
                if ($more -and $PSBoundParameters['Wait']) {
                    Start-Sleep -Milliseconds 100
                }
                # Clean out unused runspace jobs
                $temphash = $runspaces.clone()
                $temphash | Where-Object {
                    $_.runspace -eq $Null
                } | ForEach-Object {
                    Write-Verbose ("Removing {0}" -f $_.computer)
                    $Runspaces.remove($_)
                }
            } while ($more -and $PSBoundParameters['Wait'])
        }

        # region ScriptBlock
        $scriptBlock = {
            param ($Computer, $Group, $Depth, $NetBIOSDomain, $ObjNT, $Translate)
            $Script:Depth = $Depth
            $Script:ObjNT = $ObjNT
            $Script:Translate = $Translate
            $Script:NetBIOSDomain = $NetBIOSDomain
            function Get-LocalGroupMemberObj {
                [cmdletbinding()]
                param (
                    [parameter()]
                    [System.DirectoryServices.DirectoryEntry]$LocalGroup
                )
                # Invoke the Members method and convert to an array of member objects.
                $Members = @($LocalGroup.psbase.Invoke("Members")) | ForEach-Object { ([System.DirectoryServices.DirectoryEntry]$_) }
                $Counter++
                foreach ($Member in $Members) {
                    try {

                        $Name = $Member.InvokeGet("Name")
                        $Path = $Member.InvokeGet("AdsPath")

                        # Check if this member is a group.
                        $isGroup = ($Member.InvokeGet("Class") -eq "group")

                        # Remove the domain from the computername to fix the type comparison when supplied with FQDN
                        if ($Computer.Contains('.')) {
                            $Computer = $computer.Substring(0, $computer.IndexOf('.'))
                        }

                        if (($Path -like "*/$Computer/*")) {
                            $Type = 'Local'
                        } else { $Type = 'Domain' }

                        # Add Objectclass to match Get-LocalGroupMember output
                        if ($isGroup) {
                            $ObjectClass = 'Group'
                        } elseif ($isGroup -eq $false) {
                            $ObjectClass = 'User'
                        } else {
                            'Unknown'
                        }

                        New-Object PSObject -Property @{
                            Computername = $Computer
                            Name = $Name
                            PrincipalSource = $Type
                            ParentGroup = $LocalGroup.Name[0]
                            isGroup = $isGroup
                            ObjectClass = $ObjectClass
                            Depth = $Counter
                            Group = $Group
                        }
                        if ($isGroup) {
                            # Check if this group is local or domain.
                            # $host.ui.WriteVerboseLine("(RS)Checking if Counter: {0} is less than Depth: {1}" -f $Counter, $Depth)
                            if ($Counter -lt $Depth) {
                                if ($Type -eq 'Local') {
                                    if ($Groups[$Name] -notcontains 'Local') {
                                        $host.ui.WriteVerboseLine(("{0}: Getting local group members" -f $Name))
                                        $Groups[$Name] += , 'Local'
                                        # Enumerate members of local group.
                                        Get-LocalGroupMemberObj $Member
                                    }
                                } else {
                                    if ($Groups[$Name] -notcontains 'Domain') {
                                        $host.ui.WriteVerboseLine(("{0}: Getting domain group members" -f $Name))
                                        $Groups[$Name] += , 'Domain'
                                        # Enumerate members of domain group.
                                        Get-DomainGroupMember $Member $Name $True
                                    }
                                }
                            }
                        }
                    } catch {
                        $host.ui.WriteWarningLine(("GLGM{0}" -f $_.Exception.Message))
                    }
                }
            }

            function Get-DomainGroupMember {
                [cmdletbinding()]
                param (
                    [parameter()]
                    $DomainGroup,
                    [parameter()]
                    [string]$NTName,
                    [parameter()]
                    [string]$blnNT
                )
                try {
                    if ($blnNT -eq $True) {
                        # Convert NetBIOS domain name of group to Distinguished Name.
                        $objNT.InvokeMember("Set", "InvokeMethod", $Null, $Translate, (3, ("{0}{1}" -f $NetBIOSDomain.Trim(), $NTName)))
                        $DN = $objNT.InvokeMember("Get", "InvokeMethod", $Null, $Translate, 1)
                        $ADGroup = [ADSI]"LDAP://$DN"
                    } else {
                        $DN = $DomainGroup.distinguishedName
                        $ADGroup = $DomainGroup
                    }
                    $Counter++
                    foreach ($MemberDN in $ADGroup.Member) {
                        $MemberGroup = [ADSI]("LDAP://{0}" -f ($MemberDN -replace '/', '\/'))

                        # Add Objectclass to match Get-LocalGroupMember output
                        if ($MemberGroup.Class -eq "group") {
                            $ObjectClass = 'Group'
                        } else {
                            $ObjectClass = 'User'
                        }

                        New-Object PSObject -Property @{
                            Computername = $Computer
                            Name = $MemberGroup.name[0]
                            PrincipalSource = 'Domain'
                            ParentGroup = $NTName
                            isGroup = ($MemberGroup.Class -eq "group")
                            ObjectClass = $ObjectClass
                            Depth = $Counter
                            Group = $Group
                        }
                        # Check if this member is a group.
                        if ($MemberGroup.Class -eq "group") {
                            if ($Counter -lt $Depth) {
                                if ($Groups[$MemberGroup.name[0]] -notcontains 'Domain') {
                                    Write-Verbose ("{0}: Getting domain group members" -f $MemberGroup.name[0])
                                    $Groups[$MemberGroup.name[0]] += , 'Domain'
                                    # Enumerate members of domain group.
                                    Get-DomainGroupMember $MemberGroup $MemberGroup.Name[0] $False
                                }
                            }
                        }
                    }
                } catch {
                    $host.ui.WriteWarningLine(("GDGM{0}" -f $_.Exception.Message))
                }
            }
            # region Get Local Group Members
            $Script:Groups = @{}
            $Script:Counter = 0
            # Bind to the group object with the WinNT provider.
            $ADSIGroup = [ADSI]"WinNT://$Computer/$Group,group"
            Write-Verbose ("Checking {0} membership for {1}" -f $Group, $Computer)
            $Groups[$Group] += , 'Local'
            Get-LocalGroupMemberObj -LocalGroup $ADSIGroup
            # endregion Get Local Group Members
        }
        # endregion ScriptBlock
        Write-Verbose ("Checking to see if connected to a domain")
        try {
            $Domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()
            $Root = $Domain.GetDirectoryEntry()
            $Base = ($Root.distinguishedName)

            # Use the NameTranslate object.
            $Script:Translate = New-Object -ComObject "NameTranslate"
            $Script:objNT = $Translate.GetType()

            # Initialize NameTranslate by locating the Global Catalog.
            $objNT.InvokeMember("Init", "InvokeMethod", $Null, $Translate, (3, $Null))

            # Retrieve NetBIOS name of the current domain.
            $objNT.InvokeMember("Set", "InvokeMethod", $Null, $Translate, (1, "$Base"))
            [string]$Script:NetBIOSDomain = $objNT.InvokeMember("Get", "InvokeMethod", $Null, $Translate, 3)
        } catch {
            Out-Null
        }

        # region Runspace Creation
        Write-Verbose ("Creating runspace pool and session states")
        $sessionstate = [system.management.automation.runspaces.initialsessionstate]::CreateDefault()
        $runspacepool = [runspacefactory]::CreateRunspacePool(1, $Throttle, $sessionstate, $Host)
        $runspacepool.Open()

        Write-Verbose ("Creating empty collection to hold runspace jobs")
        $Script:runspaces = New-Object System.Collections.ArrayList
        # endregion Runspace Creation
    }

    process {
        foreach ($Computer in $Computername) {
            # Create the powershell instance and supply the scriptblock with the other parameters
            $powershell = [powershell]::Create().AddScript($scriptBlock).AddArgument($computer).AddArgument($Group).AddArgument($Depth).AddArgument($NetBIOSDomain).AddArgument($ObjNT).AddArgument($Translate)

            # Add the runspace into the powershell instance
            $powershell.RunspacePool = $runspacepool

            # Create a temporary collection for each runspace
            $temp = "" | Select-Object PowerShell, Runspace, Computer
            $Temp.Computer = $Computer
            $temp.PowerShell = $powershell

            # Save the handle output when calling BeginInvoke() that will be used later to end the runspace
            $temp.Runspace = $powershell.BeginInvoke()
            Write-Verbose ("Adding {0} collection" -f $temp.Computer)
            $runspaces.Add($temp) | Out-Null

            Write-Verbose ("Checking status of runspace jobs")
            Get-RunspaceData @runspacehash
        }
    }
    end {
        Write-Verbose ("Finish processing the remaining runspace jobs: {0}" -f (@(($runspaces | Where-Object { $_.Runspace -ne $Null }).Count)))
        $runspacehash.Wait = $true
        Get-RunspaceData @runspacehash

        # region Cleanup Runspace
        Write-Verbose ("Closing the runspace pool")
        $runspacepool.close()
        $runspacepool.Dispose()
        # endregion Cleanup Runspace
    }
}


function ConvertTo-HashToYN {
    <#
    .SYNOPSIS
        Used by As Built Report to convert array content true or false automatically to Yes or No.
    .DESCRIPTION
        Used by As Built Report to convert array content true or false automatically to Yes or No.
        Now also strips non-printable ASCII characters from string values while creating the array hash.
        This is required for Word Document Output as PSCribo cannot create Word documents with non-ASCII characters
    .NOTES
        Version: 0.1.1
        Author: Jonathan Colon
        Changes: 0.1.1 - Updated to include non-unicode character string cleaning. Graham Flynn - 30/07/2025
 
    .EXAMPLE
 
    .LINK
 
    #>

    [CmdletBinding()]
    [OutputType([Hashtable])]
    param (
        [Parameter (Position = 0, Mandatory)]
        [AllowEmptyString()]
        [Hashtable] $TEXT
    )

    $result = [ordered] @{}

    foreach ($i in $inObj.GetEnumerator()) {
        try {
            $valueToProcess = $i.Value

            # Check if the value is a string before attempting to clean it
            if ($valueToProcess -is [string]) {
                $valueToProcess = $valueToProcess | Remove-NonPrintableAscii
            }

            $convertedValue = ConvertTo-TextYN $valueToProcess

            $result.add($i.Key, $convertedValue)
        } catch {
            # If ConvertTo-TextYN fails, still try to clean the original value if it's a string
            $originalValue = $i.Value
            if ($originalValue -is [string]) {
                $originalValue = $originalValue | Remove-NonPrintableAscii
            }
            $result.add($i.Key, ($originalValue)) # Add the (potentially cleaned) original value
        }
    }
    if ($result) {
        return $result
    } else {
        # If $TEXT was empty or processing failed to produce results, return the original (empty) $TEXT
        # Note: If $inObj was the source, and $TEXT is not used, this 'else' block might need review
        # based on how $TEXT is intended to be used when $inObj is empty.
        return $TEXT
    }
} # end



function Remove-NonPrintableAscii {
    <#
    .SYNOPSIS
        Removes non-printable ASCII characters from a string.
    .DESCRIPTION
        This function takes a string as input and returns a new string
        where all characters outside the printable ASCII range (ASCII 32-126)
        have been removed. If the input is null or empty, it returns an empty string.
    .PARAMETER InputString
        The string from which to remove non-printable ASCII characters.
    .EXAMPLE
        Remove-NonPrintableAscii -InputString "Hello`nWorld`t!"
        # Output: "HelloWorld!"
 
    .EXAMPLE
        "This string has a null character: `0" | Remove-NonPrintableAscii
        # Output: "This string has a null character: "
 
    .EXAMPLE
        $null | Remove-NonPrintableAscii
        # Output: "" (empty string)
 
    .EXAMPLE
        "" | Remove-NonPrintableAscii
        # Output: "" (empty string)
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([String])]

    param (
        [Parameter(ValueFromPipeline = $true)]
        [string]$InputString
    )

    process {
        if ($PSCmdlet.ShouldProcess($InputString, "Remove non-printable ASCII characters")) {
            # Check if the input string is null or empty.
            # If it is, return an empty string immediately to avoid errors.
            if ([string]::IsNullOrEmpty($InputString)) {
                return ""
            }

            # Regular expression to match any character that is NOT a printable ASCII character.
            # [^\x20-\x7E] matches any character that is not in the range of ASCII 32 (space) to 126 (tilde).
            $cleanedString = $InputString -replace '[^\x20-\x7E]', ''
            return $cleanedString
        }
    }
}