functions/New-PSFormatXML.ps1


Function New-PSFormatXML {
    [cmdletbinding(SupportsShouldProcess)]
    [alias("nfx")]
    [OutputType("None", "System.IO.FileInfo")]

    Param(
        [Parameter(Mandatory, ValueFromPipeline, HelpMessage = "Specify an object to analyze and generate or update a ps1xml file.")]
        [object]$InputObject,
        [Parameter(HelpMessage = "Enter a set of properties to include. The default is all. If specifying a Wide entry, only specify a single property.")]
        [object[]]$Properties,
        [Parameter(HelpMessage = "Specify the object typename. If you don't, then the command will use the detected object type from the InputObject.")]
        [string]$Typename,
        [Parameter(HelpMessage = "Specify whether to create a table ,list or wide view")]
        [ValidateSet("Table", "List", "Wide")]
        [string]$FormatType = "Table",
        [string]$ViewName = "default",
        [Parameter(Mandatory, HelpMessage = "Enter full filename and path for the format.ps1xml file.")]
        [ValidateNotNullOrEmpty()]
        [string]$Path,
        [Parameter(HelpMessage = "Specify a property name to group on.")]
        [ValidateNotNullOrEmpty()]
        [string]$GroupBy,
        [Parameter(HelpMessage = "Wrap long lines. This only applies to Tables.")]
        [Switch]$Wrap,
        [switch]$Append,
        [switch]$PassThru
    )
    Begin {
        Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"

        #convert the parent path into a real file system path
        $parent = Convert-Path -Path (Split-Path -Path $path)
        #reconstruct the path
        $realPath = Join-Path -Path $parent -ChildPath (Split-Path -Path $path -Leaf)

        if (-Not $Append) {
            Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Initializing a new XML document"
            [xml]$Doc = New-Object -TypeName System.Xml.XmlDocument

            <#
            Disabling this because the declaration results in saving the file as UTF8 with BOM
            https://github.com/dotnet/runtime/issues/28218
            JDH 6/30/2021

  #create declaration
            $dec = $Doc.CreateXmlDeclaration("1.0", "UTF-8", $null)
            #append to document
            [void]$doc.AppendChild($dec) #>


            $text = @"

Format type data generated $(Get-Date) by $env:USERDOMAIN\$env:username

This file was created using the New-PSFormatXML command that is part
of the PSScriptTools module.

https://github.com/jdhitsolutions/PSScriptTools

"@


            [void]$doc.AppendChild($doc.CreateComment($text))

            #create Configuration Node
            $config = $doc.CreateNode("element", "Configuration", $null)
            $viewdef = $doc.CreateNode("element", "ViewDefinitions", $null)

        }
        elseif ($Append -AND (Test-Path -Path $realPath)) {
            Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Opening format document $RealPath"
            [xml]$Doc = Get-Content -Path $realPath
        }
        else {
            Throw "Failed to find $Path"
        }
        $view = $doc.CreateNode("element", "View", $null)
        [void]$view.AppendChild($doc.CreateComment("Created $(Get-Date) by $env:USERDOMAIN\$env:username"))
        $name = $doc.CreateElement("Name")
        $name.InnerText = $ViewName
        [void]$view.AppendChild($name)
        $select = $doc.createnode("element", "ViewSelectedBy", $null)

        if ($GroupBy) {
            Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Grouping by $GroupBy"

            $groupcomment = @"

            You can also use a scriptblock to define a custom property name.
            You must have a Label tag.
            <ScriptBlock>`$_.machinename.ToUpper()</ScriptBlock>
            <Label>Computername</Label>

            Use <Label> to set the displayed value.

"@

            $group = $doc.CreateNode("element", "GroupBy", $null)
            [void]$group.AppendChild($doc.CreateComment($groupcomment))
            $groupProp = $doc.CreateNode("element", "PropertyName", $null)
            $groupProp.InnerText = $GroupBy
            $groupLabel = $doc.CreateNode("element", "Label", $null)
            $groupLabel.InnerText = $GroupBy
            [void]$group.AppendChild($groupProp)
            [void]$group.AppendChild($groupLabel)
        }

        Switch ($FormatType) {
            "Table" {
                $table = $doc.CreateNode("element", "TableControl", $null)
                $headers = $doc.CreateNode("element", "TableHeaders", $null)
                $TableRowEntries = $doc.CreateNode("element", "TableRowEntries", $null)
                $entry = $doc.CreateNode("element", "TableRowEntry", $null)
                if ($Wrap) {
                    Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Adding Wrap"
                    $wrapelement = $doc.CreateNode("element", "Wrap", $null)
                    [void]($entry.AppendChild($wrapelement))
                }
            }
            "List" {
                $list = $doc.CreateNode("element", "ListControl", $null)
                $ListEntries = $doc.CreateNode("element", "ListEntries", $null)
                $ListEntry = $doc.CreateNode("element", "ListEntry", $null)
            }
            "Wide" {
                $Wide = $doc.CreateNode("element", "WideControl", $null)
                $wideEntries = $doc.CreateNode("element", "WideEntries", $null)
                $WideEntry = $doc.CreateNode("element", "WideEntry", $null)
            }
        }
        $counter = 0
    } #begin

    Process {
        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Counter $counter"
        If ($counter -eq 0) {
            if ($Typename) {
                $tname = $TypeName
            }
            else {
                $tname = $InputObject.PSObject.TypeNames[0]
            }
            $tnameElement = $doc.CreateElement("TypeName")
            #you can't use [void] on a property assignment and we don't want to see the XML result
            $tnameElement.InnerText = $tname
            [void]$select.AppendChild($tnameElement)
            [void]$view.AppendChild($select)

            if ($group) {
                [void]$view.AppendChild($group)
            }

            Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Creating an format document for object type $tname "

            #get property members
            $objProperties = $InputObject.PSObject.properties
            Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Using object properties: $($objProperties.name -join ',')"
            $members = @()
            if ($properties) {
                foreach ($property in $properties) {
                    if ($property.gettype().Name -match "hashtable") {
                        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Detected a hashtable defined property called named $($property.name)"
                        $members += $property
                    }
                    else {
                        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Validating property: $property [$($property.gettype().name)]"
                        $test = ($objProperties).where( { $_.name -like $property })
                        if ($test) {
                            $members += $test
                        }
                        else {
                            Write-Warning "Can't find a property called $property on this object. Did you enter it correctly? The ps1xml file will NOT be created."
                            $BadProperty = $True
                            #Bail out
                            return
                        }
                    }
                }
            }
            else {
                #use auto detected properties
                Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Auto detected $($objProperties.name.count) properties"
                $members = $objProperties
            }

            #remove GroupBy property from collection of properties
            if ($GroupBy) {
                Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Removing GroupBy property $GroupBy from the list of properties"
                $members = $members | Where-Object { $_.name -ne $GroupBy }
            }
            Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $($members.name.count) properties"

            $comment = @"

            By default the entries use property names, but you can replace them with scriptblocks.
            <ScriptBlock>`$_.foo /1mb -as [int]</ScriptBlock>

"@

            if ($FormatType -eq 'Table') {

                $items = $doc.CreateNode("element", "TableColumnItems", $null)
                [void]$items.AppendChild($doc.CreateComment($comment))

                foreach ($member in $members) {
                    #account for null property values
                    if ($member.Expression) {
                        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] $($member.name) has an Expression script block $($member.expression | Out-String)"
                        $member.Value = "$($member.expression | Out-String)".Trim()
                        $isScriptBlock = $True
                    }
                    elseif (-Not $member.value) {
                        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] $($member.name) has a null value. Inserting a placeholder."
                        #$member.value = " "
                        $isScriptBlock = $False
                    }
                    else {
                        $isScriptBlock = $False
                    }

                    $th = $doc.createNode("element", "TableColumnHeader", $null)

                    Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS]... $($member.name)"
                    $label = $doc.CreateElement("Label")
                    $label.InnerText = $member.Name
                    [void]$th.AppendChild($label)
                    $width = $doc.CreateElement("Width")
                    <#
                        set initial width to value length + 3
                        Use the width of whichever is longer, the name or value

                        If the value is a ScriptBlock, need to get the length of the
                        result and not the code length. But this may be impossible so
                        as a compromise use a value length of 12
                        Issue #94
                    #>

                    if ($isScriptBlock) {
                        $ValueLength = 12
                    }
                    elseif ($Member.value) {
                        $ValueLength = $Member.value.ToString().length
                    }
                    else {
                        #set a default length.
                        $valueLength = $label.InnerText.Length
                    }
                    $longest = $valueLength, $member.name.length | Sort-Object | Select-Object -Last 1
                    $width.InnerText = $longest + 3
                    [void]$th.AppendChild($width)

                    $align = $doc.CreateElement("Alignment")
                    $align.InnerText = "left"
                    [void]$th.AppendChild($align)
                    [void]$headers.AppendChild($th)

                    $tci = $doc.CreateNode("element", "TableColumnItem", $null)
                    if ($isScriptBlock) {
                        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Creating a ScriptBlock element"
                        $prop = $doc.CreateElement("ScriptBlock")
                        $prop.InnerText = "$($member.Value)"
                    }
                    else {
                        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Creating a PropertyName element"
                        $prop = $doc.CreateElement("PropertyName")
                        $prop.InnerText = $member.name
                    }
                    [void]$tci.AppendChild($prop)
                    [void]$items.AppendChild($tci)
                }
            }
            elseif ($FormatType -eq 'List') {
                #create a list
                $items = $doc.CreateNode("element", "ListItems", $null)
                [void]$items.AppendChild($doc.CreateComment($comment))
                foreach ($member in $members) {
                    #account for null property values
                    if ($member.Expression) {
                        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] $($member.name) has an Expression script block $($member.expression | Out-String)"
                        $member.Value = "$($member.expression | Out-String)".Trim()
                        $isScriptBlock = $True
                    }
                    elseif (-Not $member.value) {
                        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] $($member.name) has a null value. Inserting a placeholder."
                        $member.value = " "
                        $isScriptBlock = $False
                    }
                    else {
                        $isScriptBlock = $False
                    }

                    $li = $doc.CreateNode("element", "ListItem", $null)
                    $label = $doc.CreateElement("Label")
                    $label.InnerText = $member.Name
                    if ($isScriptBlock) {
                        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Creating a ScriptBlock element"
                        $prop = $doc.CreateElement("ScriptBlock")
                        $prop.InnerText = "$($member.Value)"
                    }
                    else {
                        Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Creating a PropertyName element"
                        $prop = $doc.CreateElement("PropertyName")
                        $prop.InnerText = $member.name
                    }
                    [void]$li.AppendChild($label)
                    [void]$li.AppendChild($prop)
                    [void]$items.AppendChild($li)
                }
            }
            else {
                #create Wide
                #there should only be one element in the array

                $item = $doc.CreateNode("element", "WideItem", $null)
                [void]$item.AppendChild($doc.CreateComment($comment))
                Write-Verbose "Using $($members.name)"
                if ($members.Expression) {
                    Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Creating a ScriptBlock element"
                    $prop = $doc.CreateElement("ScriptBlock")
                    $prop.InnerText = "$($members.Expression)"
                }
                else {
                    Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Creating a PropertyName element"
                    $prop = $doc.CreateElement("PropertyName")
                    $prop.InnerText = $member.name
                }
                [void]$item.AppendChild($prop)
            }
        }
        else {
            #only display the warning on the first additional object
            if ($counter -eq 1) {
                Write-Warning "Ignoring additional objects. This command only needs one instance of an object to create the ps1xml file. Your file will still be created."
            }
        }
        $counter++
    } #process

    End {

        if (-Not $BadProperty) {
            #don't create the file if a bad property as specified. Issue #111
            Write-Verbose "[$((Get-Date).TimeOfDay) END ] Finalizing XML"
            #Add elements to each parent
            if ($FormatType -eq 'Table') {
                [void]$entry.AppendChild($items)
                [void]$TableRowEntries.AppendChild($entry)
                [void]$table.AppendChild($doc.CreateComment("Delete the AutoSize node if you want to use the defined widths."))
                $auto = $doc.CreateElement("AutoSize")
                [void]$table.AppendChild($auto)
                [void]$table.AppendChild($headers)
                [void]$table.AppendChild($TableRowEntries)

                [void]$view.AppendChild($table)
            }
            elseif ($FormatType -eq 'List') {
                [void]$listentry.AppendChild($items)
                [void]$listentries.AppendChild($listentry)
                [void]$list.AppendChild($listentries)
                [void]$view.AppendChild($list)
            }
            else {
                #Wide
                [void]$wideEntries.AppendChild($WideEntry)
                [void]$WideEntry.AppendChild($item)
                [void]$Wide.AppendChild($doc.CreateComment("Delete the AutoSize node if you want to use PowerShell defaults."))
                $auto = $doc.CreateElement("AutoSize")
                [void]$Wide.AppendChild($auto)
                [void]$Wide.AppendChild($wideEntries)
                [void]$view.AppendChild($Wide)
            }

            if ($append) {
                Write-Verbose "[$((Get-Date).TimeOfDay) END ] Appending to existing XML"
                [void]$doc.Configuration.ViewDefinitions.AppendChild($View)
            }
            else {
                [void]$viewdef.AppendChild($view)
                [void]$config.AppendChild($viewdef)
                [void]$doc.AppendChild($config)
            }

            Write-Verbose "[$((Get-Date).TimeOfDay) END ] Saving to $realpath"
            if ($PSCmdlet.ShouldProcess($realPath, "Adding $formattype view $viewname")) {
                $doc.Save($realPath)
                if ($PassThru) {
                    if ($host.name -match "Visual Studio Code") {
                        #If you run this command in VS Code and specify -PassThru, then open the file
                        #for further editing
                        Open-EditorFile -Path $realpath
                    }
                    elseif ($host.name -match "PowerShell ISE") {
                        psedit $realpath
                    }
                    else {
                        Get-Item $realPath
                    }
                }
            }
        } #if not bad property

        Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"

    } #end

} #close New-PSFormatXML