TrackGpo.psm1

function Invoke-GpoTracking {
    <#
    .SYNOPSIS
    This function will export all GPO info into restorable objects and place them into a folder for easy access
 
    .DESCRIPTION
    This function aims to make life easy for SysAdmins everywhere who work with Group Policy Objects (GPOs) and want to be able to audit/detect changes to them. Each folder backup is a GroupPolicy compatible backup that can be restored at will in order to revert changes or restore an accidently deleted GPO. They also contain a summary document that makes it easy to digest the state of each folder if you need to dig that deep.
 
    The default settings provide a resilient backup snapshot for all GPOs in your domain. Though you can override many settings as needed, you generally shouldn't need to. This includes disabling the git repo functionality, or deleting policies entirely from the backup folder when they get deleted from your domain. Defaults include the following settings:
 
    * Will update the Git repo ONLY if less than 10% of GPOs have changed since the last run
    * When a GPO is deleted from the domain:
      -all versions of the GPO get removed from the git repo
      -all versions of the GPO remain in the folder
    * Any change diff will include 3 lines of context above and below the change and also the common stuff about revision number and modified date
 
    This cmdlet depends on having Git installed and available for its diff capabilities.
 
    In order to make the MOST of this function, you need to create your own functions for two external events that can happen:
    * Normal GPO additions, changes, and deletions
    * Errors during processing
 
    Do so by creating a function or module named New-TrackGpoTicket_External and New-TrackGpoError_External respectively.
    The private functions in this repo will pass the same parameters to your external version of the function if you create one.
    You can also install the PSGallery module TrackGpo_Builtin for some samples to work off of. See: https://gitlab.com/devirich/trackgpo_builtin
 
    .PARAMETER GpoRepo
    The folder path to store the backed up GPOs to.
 
    .PARAMETER WorkingDir
    The folder path to temporarily store fresh backup for all GPOs for comparison.
 
    .PARAMETER Initialize
    By default, this function does NOT create folders or init a git repo. Enable this switch to turn on these features
 
    .PARAMETER ChangeRemovePercentMaxDelta
    Maximum percentage of change allowed in removals or additions before the script throws an error
 
    .PARAMETER RemoveOldPolicyVersions
    When a GPO is changed, this function will keep both folders. This switch makes it so that all old versions of the policy get removed.
 
    .PARAMETER RemoveDeletedPolicies
    Enable this switch if you want the backups folder to ONLY contain GPOs that are live on your domain.
 
    .PARAMETER DisableGitRepo
    Enable this switch if you hate git repos or have a legit worry about storing GPOs in a git repo history.
 
    .PARAMETER SkipCommonChanges
    Enable this switch if you don't want to see the modified date and revision number in the change documentation. See the wiki for an edge case to be aware of.
 
    .PARAMETER GpoChangeContext
    When a GPO is changed, the diff by default will show context around the actual changed lines. This parameter affects how many lines of context to show.
 
 
    .EXAMPLE
    PS> Invoke-GpoTracking -GpoRepo GpoStore -WorkingDir GpoStore_working -Initialize -WhatIf
 
    What if: Performing the operation "Create Directory" on target "Destination: C:\Protected\GpoStore".
    What if: Performing the operation "Create Directory" on target "Destination: C:\Protected\GpoStore_working".
    What if: Performing the operation "Push-Location" on target "C:\Protected\GpoStore".
    What if: Performing the operation "Remove-Item" on target "C:\Protected\GpoStore_working\*".
    What if: Performing the operation "git `reset --hard`" on target "C:\Protected\GpoStore".
    What if: Performing the operation "Compare GroupPolicy to repo" on target "C:\Protected\GpoStore".
    What if: Back up all the GPOs in the domain.test domain to the following location: C:\Protected\GpoStore_working. (Backup-GPO)
    What if: Performing the operation "Set-Content" on target "$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).summary (Foreach-Object)".
    What if: Performing the operation "Compare GPO GUIDs and reconcile changes" on target "`ls $GpoRepo\*\*.summary` and `ls $WorkingDir\*\*.summary`".
    What if: Performing the operation "Process any new GPO objects." on target "$WorkingDir\<FOLDER>".
    What if: Performing the operation "Process any updated GPO objects." on target "$WorkingDir\<FOLDER(S)>".
    What if: Performing the operation "Process any removed GPO objects." on target "$GpoRepo\<FOLDERS>".
    What if: Performing the operation "Remove-Item" on target "C:\Protected\GpoStore_working\*".
    What if: Performing the operation "git `reset --hard`" on target "C:\Protected\GpoStore".
 
    Enabling -Initialize to automatically create folders and using -WhatIf to get a view of what actions will be taken and where.
    Confirm that these actions and paths are what you expect.
    Remove -WhatIf and let the script run!
 
    .EXAMPLE
    PS> Invoke-GpoTracking -GpoRepo GpoStore -WorkingDir GpoStore_working
 
    Use this for subsequent runs if you're ok with the defaults.
 
    .EXAMPLE
    PS> Invoke-GpoTracking -GpoRepo GpoStore -WorkingDir GpoStore_working -RemoveOldPolicyVersions -RemoveDeletedPolicies -DisableGitRepo -GpoChangeContext 0 -SkipCommonChanges
 
    This example will: Disable git functionality and make the repo folder a 1:1 match of live group policies in the domain.
    It also will set the context around each GPO diff to 0 lines above and below and skip showing the GPO revision number and modified date.
 
    .NOTES
    Original Publish date: 31Oct2018
    Hope you like it!
    #>

    [cmdletbinding(
        SupportsShouldProcess,
        ConfirmImpact = "Medium"
    )]
    Param(
        [string]$GpoRepo,
        [string]$WorkingDir,
        [switch]$Initialize,
        [ValidateRange(0, 100)]
        [int]$ChangeRemovePercentMaxDelta = 10,
        [switch]$RemoveOldPolicyVersions,
        [switch]$RemoveDeletedPolicies,
        [switch]$DisableGitRepo,
        [switch]$SkipCommonChanges,
        [int]$GpoChangeContext = 3
    )
    $ErrorActionPreference = "Stop"

    # Need to ensure that Git is installed and accessible as expected
    try { git | Out-Null }
    catch {
        $Message = "Unable to run `git`. Exiting. Install Git or create an alias to run `git` if installed"
        New-TrackGpoError $Message
        throw $Message
    }

    $GpoRepo = Resolve-Path_Force $GpoRepo
    $WorkingDir = Resolve-Path_Force $WorkingDir

    if (Test-Path $GpoRepo) {} # Good to go. I just hate nested if statements
    elseif ($Initialize) { mkdir $GpoRepo }
    else {
        $Message = "$GpoRepo does not exist and -Initialize was not specified. Exiting."
        New-TrackGpoError $Message
        throw $Message
    }

    if (Test-Path $WorkingDir) {} # Good to go. I just hate nested if statements
    elseif ($Initialize) { mkdir $WorkingDir }
    else {
        $Message = "WorkingDir does not exist and -Initialize was not specified. Exiting."
        New-TrackGpoError $Message
        throw $Message
    }

    try {
        if ($pscmdlet.ShouldProcess($GpoRepo, 'Push-Location')) {
            Push-Location $GpoRepo
        }
        try { $Status = git -C $GpoRepo status 2>$null } catch {}
        if ($DisableGitRepo -or $Status) {} # Good to go.
        elseif ($Initialize) { git init }
        else {
            throw "GpoRepo is not a git repo and -Initialize was not specified. Exiting."
        }

        # Want working dir and git repo in a fresh state
        if ($pscmdlet.ShouldProcess("$WorkingDir\*", 'Remove-Item')) {
            Remove-Item $WorkingDir\* -Recurse -Force
        }
        if (-not $DisableGitRepo -and $pscmdlet.ShouldProcess($GpoRepo, 'git `reset --hard`')) {
            git reset --hard | Out-Null
        }

        if ($pscmdlet.ShouldProcess($GpoRepo, 'Compare GroupPolicy to repo')) {
            # Get the newest version of each GPO based on GUID
            if ($PSBoundParameters.DisableGitRepo) {
                $GpoRepo_LatestGpos = Get-ChildItem $GpoRepo\*\*.summary
            }
            else {
                $GpoRepo_LatestGpos = git ls-files *\*.summary | Get-ChildItem
            }
            Write-Verbose "Found $($GpoRepo_LatestGpos.count) items in repo"
            $GpoRepo_LatestGpos = $GpoRepo_LatestGpos | Sort-Object -Desc LastWriteTime | Group-Object Name | ForEach-Object { $_.Group[0] }
            Write-Verbose "Found $($GpoRepo_LatestGpos.count) items in repo"

            $PercentChanged = Get-TrackGpoDeltaPercent -GpoRepo_LatestGpos $GpoRepo_LatestGpos
            if ($PercentChanged -gt $ChangeRemovePercentMaxDelta -and -not $PSBoundParameters.Initialize) {
                throw "Too many added or removed GPOs. $PercentChanged% of existing $($GpoRepo_LatestGpos.count) policies have been added or deleted. It should be at or under $ChangeRemovePercentMaxDelta% changed. Change -ChangeRemovePercentMaxDelta or determine why so many are listed as having added/removed."
            }
        }

        try {
            #Region Export GPOs and Summary files to working dir
            $i = 0
            Backup-GPO -All -Path $WorkingDir | ForEach-Object {
                $i++
                Write-Progress -Activity "Backing up GPO reports" -Status "Processing policy number: $i" -CurrentOperation $_.DisplayName
                Get-GPOReport -ReportType Html -Guid $_.GpoId | Select-Object -OutVariable GpoReport_html | Out-Null
                Set-Content "$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).htm" -Value $GpoReport_html
                # Data collected data will always be different. Need to remove it before storing summary for comparison:
                $GpoReport_html = $GpoReport_html -replace '<td id="dtstamp">.*</td>'
                # We want the comparison to be as neat as possible. Strip HTML data-
                $GpoReport = Remove-HtmlContent $GpoReport_html
                Set-Content "$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).summary" -Value $GpoReport
            }
            #These commands are needed because -WhatIf processing will NOT reach the inner loop of the above foreach.
            if ($pscmdlet.ShouldProcess('$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).summary (Foreach-Object)', "Set-Content")) {}
            #EndRegion Export GPOs and Summary files to working dir
        }
        catch {
            throw "Backing up GPO's failed. Exiting immediately: $($_.Exception.Message)"
        }



        if ($pscmdlet.ShouldProcess('`ls $GpoRepo\*\*.summary` and `ls $WorkingDir\*\*.summary`', "Compare GPO GUIDs and reconcile changes")) {
            $WorkingFileIO = Get-ChildItem $WorkingDir\*\*.summary

            if ($GpoRepo_LatestGpos -and $WorkingFileIO) {
                $i = 0
                $ComparisonCases = Compare-Object $GpoRepo_LatestGpos $WorkingFileIO -prop Name -IncludeEqual | Sort-Object SideIndicator
                foreach ($Comparison in $ComparisonCases) {
                    $i++
                    Write-Progress -Activity "Comparing policies" -CurrentOperation $Comparison.Name -PercentComplete ($i / $ComparisonCases.Count * 100)
                    $Gpo = $Comparison
                    switch ($Comparison.SideIndicator) {
                        "<=" {
                            #Previously existed. Not present anymore.
                            $VersionsOfGpo = Get-ChildItem $GpoRepo\*\$($Gpo.Name)
                            $LatestVersionOfGpo = $VersionsOfGpo | Sort-Object -Desc LastWriteTime | Select-Object -First 1
                            $GpoInfo = Get-GpoInfo ($LatestVersionOfGpo.FullName -replace "summary", "htm")
                            Write-Verbose "Removing GPO: $($GpoInfo.Title)"
                            $CommitMessage = New-TrackGpoTicket -GpoInfo $GpoInfo -Type Remove

                            $VersionsOfGpo | ForEach-Object {
                                if ($RemoveDeletedPolicies -or $DisableGitRepo) { Remove-Item -Recurse -Force $_.DirectoryName }
                                if (-not $DisableGitRepo -and (git ls-files $_.DirectoryName)) { git rm --cached -r $_.DirectoryName }
                            }
                            if (-not $DisableGitRepo) {
                                if ([string]::IsNullOrWhiteSpace($CommitMessage)) {
                                    $CommitMessage = "Remove: {0}" -f $GpoInfo.Title
                                }
                                git commit -m $CommitMessage
                            }
                        }
                        "=>" {
                            #Just created!
                            $GpoReport = $WorkingFileIO | Where-Object Name -EQ $Gpo.Name
                            $HeadGpoFolder = Move-Item $GpoReport.DirectoryName $GpoRepo -PassThru
                            $HeadGpoReport = Get-ChildItem -Path $HeadGpoFolder\*.summary

                            $GpoInfo = Get-GpoInfo ($HeadGpoReport.FullName -replace "summary", "htm")
                            Write-Verbose "Adding GPO: $($GpoInfo.Title)"
                            $CommitMessage = New-TrackGpoTicket -GpoInfo $GpoInfo -Type Add

                            if (-not $DisableGitRepo) {
                                if ([string]::IsNullOrWhiteSpace($CommitMessage)) {
                                    $CommitMessage = "Add: {0}" -f $GpoInfo.Title
                                }
                                git add $HeadGpoReport.DirectoryName
                                git commit -m $CommitMessage
                            }
                        }

                        "==" {
                            # Exists previously and still exists.
                            # Most of the time, this is what gets run.
                            $ExistingGpoObject = $GpoRepo_LatestGpos | Where-Object Name -EQ $Gpo.Name
                            $UpdatedGpoObject = $WorkingFileIO | Where-Object Name -EQ $Gpo.Name
                            $Format = "U$GpoChangeContext"
                            $DiffResults = git diff --shortstat --no-index -$Format -p --ignore-all-space $ExistingGpoObject.FullName $UpdatedGpoObject.FullName

                            if ($DiffResults) {
                                # need to pull out the stats on line 1 and discard line number 2 so that the results are ready for parsing
                                $DiffStats, $null, $DiffResults = $DiffResults
                                $Diff = ConvertFrom-Diff $DiffResults
                                if ($SkipCommonChanges) { $DiffResults = $Diff.ToString("-", "User Revisions|Computer Revisions", 2) }
                                else { $DiffResults = $Diff.ToString() }

                                $GpoInfo = Get-GpoInfo ($UpdatedGpoObject.FullName -replace "summary", "htm")
                                $Splat = @{
                                    GpoInfo = $GpoInfo
                                    Type    = "Change"
                                    Diff    = $DiffResults
                                    Stats   = $DiffStats
                                }
                                $CommitMessage = New-TrackGpoTicket @Splat

                                Move-Item $UpdatedGpoObject.DirectoryName $GpoRepo
                                if ($RemoveOldPolicyVersions) {
                                    Get-ChildItem $GpoRepo\*\$($Gpo.Name) |
                                    Sort-Object -Desc LastWriteTime |
                                    Select-Object -Skip 1 |
                                    ForEach-Object {
                                        Write-Verbose "Removing previous version of GPO backup: $($_.Directory)"
                                        $_.Directory | Remove-Item -Recurse -Force
                                        if (-not $DisableGitRepo) {
                                            git rm -r $_.DirectoryName
                                        }
                                    }
                                }
                                if (-not $DisableGitRepo) {
                                    if ([string]::IsNullOrWhiteSpace($CommitMessage)) {
                                        $CommitMessage = "Add: {0}" -f $GpoInfo.Title
                                    }
                                    Write-Verbose "Adding modified GPO to repo - $($Gpo.BaseName)"
                                    git add (Split-Path -Leaf $UpdatedGpoObject.DirectoryName)
                                    git commit -m $CommitMessage
                                }
                            }
                            else {
                                Write-Verbose "GPO has not changed. Removing from working."
                                Remove-Item $UpdatedGpoObject.DirectoryName -Recurse -Force
                            }
                        }
                    }
                }
            }
            elseif ($GpoRepo_LatestGpos) {
                throw "I'm scared: All GPOs removed from domain??! Or other error. Ya, you should look carefully at what is causing this."
            }
            elseif ($WorkingFileIO) {
                Write-Verbose "No GPOs currently exist in head! Adding all GPOs to git."
                foreach ($GpoReport in $WorkingFileIO) {
                    $MovedItem = Move-Item $GpoReport.DirectoryName $GpoRepo -PassThru
                    $HeadGpoReport = Get-ChildItem -Path $MovedItem\*.summary
                    $GpoInfo = Get-GpoInfo ($HeadGpoReport.FullName -replace "summary", "htm")
                    if (-not $DisableGitRepo) {
                        Write-Verbose "Committing to head with comment: $($GpoInfo['Title'])"
                        git add $HeadGpoReport.DirectoryName
                        git commit -m "Init domain: $($GpoInfo.Title)"
                    }
                }
            }
            else {
                throw "No files in head or working! What's going on here anyway!?!"
            }
        }
        if ($pscmdlet.ShouldProcess('$WorkingDir\<FOLDER>', "Process any new GPO objects.")) {}
        if ($pscmdlet.ShouldProcess('$WorkingDir\<FOLDER(S)>', "Process any updated GPO objects.")) {}
        if ($pscmdlet.ShouldProcess('$GpoRepo\<FOLDERS>', "Process any removed GPO objects.")) {}


        if ($pscmdlet.ShouldProcess("$WorkingDir\*", 'Remove-Item')) {
            Remove-Item $WorkingDir\* -Recurse -Force
        }
        if (-not $DisableGitRepo -and $pscmdlet.ShouldProcess($GpoRepo, 'git `reset --hard`')) {
            git reset --hard | Out-Null
        }
        Pop-Location
    }
    catch {
        Pop-Location
        $Message = $_.Exception.Message
        New-TrackGpoError $Message
        throw $Message
    }
}
function ConvertFrom-Diff {
    [CmdletBinding()]
    param (
        [string[]]$In
    )
    $String = $In -join "`n"
    $Sections = $String -replace "`r" -split "(?m)`n(?=^diff)"
    $Sections | ForEach-Object { [Diff]::new($_) }
}
class Diff {
    [string]$Header
    [string[]]$ExtendedHeaders
    [string]$From
    [string]$To
    [array[]]$Hunk

    Diff() {}
    Diff([string[]]$String) {
        # Need to ensure that whether you input an array of strings, or a string with multiple lines, or a combo,
        # that it ends up as an array of strings in a queue collection:
        $In = $String -join "`n"
        [System.Collections.Generic.Queue[string]]$Q = $In -split "`n"

        # Need to make sure that Q is properly populated. If so, convert to our object!
        if ($Q.Count -and $Q.Peek() -match "^diff") {
            $this.Header = $Q.Dequeue()
            $this.ExtendedHeaders = while ($Q.Peek() -notmatch "^---") {
                $Q.Dequeue()
            }
            $this.From = $Q.Dequeue()
            $this.To = $Q.Dequeue()
            $this.Hunk = $Q -join "`n" -split "(?m)`n(?=^@@)"
        }
    }

    [string[]] ToString() {
        return $this.ToString($null, $null, $null)
    }
    [string[]] ToString([string]$ExcludeHunkPattern) {
        return $this.ToString("-", $ExcludeHunkPattern, 2)
    }
    [string[]] ToString([string]$Modifier, [string]$HunkPattern, [int]$SearchScope) {
        [string[]]$Out = @()
        # SearchScope is used with -SkipCommonChanges to search in the first couple lines for the Computer or User revisions fields.
        # This feels a bit like a hack. Cause it is.
        switch ($Modifier) {
            "+" { $out += $this.Hunk | Select-Object -First $SearchScope | Where-Object { $_ -match $HunkPattern } }
            "-" { $out += $this.Hunk | Select-Object -First $SearchScope | Where-Object { $_ -notmatch $HunkPattern } }
            default { $out += $this.Hunk | Select-Object -First $SearchScope }
        }
        $out += $this.Hunk | Select-Object -Skip $SearchScope

        return $out | ForEach-Object { $_ -split "`n" }
    }
}
function Get-GpoInfo ([string]$FilePath) {
    $GpoContents = Get-Content $FilePath
    $GpoInfo = [ordered]@{
        Title           = [regex]::Match($GpoContents, '(?<=<title>).*?(?=</title>)').Value
        Created         = [regex]::Match($GpoContents, '(?<="row">Created</td><td>).*?(?=</td></tr>)').Value
        Modified        = [regex]::Match($GpoContents, '(?<="row">Modified</td><td>).*?(?=</td></tr>)').Value
        GUID            = [regex]::Match($GpoContents, '(?<="row">Unique ID</td><td>).*?(?=</td></tr>)').Value
        'GPO Status'    = [regex]::Match($GpoContents, '(?<="row">GPO Status</td><td>).*?(?=</td></tr>)').Value
        'Enabled Links' = [regex]::Matches($GpoContents, '(?<=<td>Enabled</td><td>)feb.com/.*?(?=</td>)').Value -join "`n"
    }
    $GpoInfo
}
function Get-TrackGpoDeltaPercent {
    <#
    .SYNOPSIS
    Returns a percentage as 0-100 of how many GPOs are not common between the domain and a list of GPO ids
 
    .PARAMETER GpoRepo_LatestGpos
    Array of GPOs that was the last current snapshot of the domain
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param (
        [Parameter()]
        $GpoRepo_LatestGpos
    )

    # Need a baseline of all previous GPOs in order to track possible failres

    if ($DomainGpos = Get-GPO -All) {
        # This check is placed inside the Get-GPO block to ensure that Get-GPO works even when there
        # are no existing GPO's getting tracked.
        if ($GpoRepo_LatestGpos) {
            Write-Verbose "Comparing $($DomainGpos.ID.Count) domain GPOs to $($GpoRepo_LatestGpos.BaseName.count) Repo GPOs"
            $Same,$Diff = (Compare-Object $DomainGpos.ID $GpoRepo_LatestGpos.BaseName -IncludeEqual).Where({$_.SideIndicator -eq "=="},"Split")
            $Diff.Count / ($Same.Count + $Diff.Count) * 100
        }
        else {
            # When there are no existing GPOs getting tracked, everything is changed. Return 100%
            Write-Verbose "No repo GPOs. 100% changed!"
            100
        }
    }
    else {
        throw "Could not get domain Group Policy Objects (GPOs). Please fix this issue. Permissions?"
    }
}
function New-TrackGpoError {
    param(
        [parameter(Mandatory)]
        [String]$Message
    )
    $Splat = @{
        Message = $Message
    }
    $CommandName = $MyInvocation.InvocationName + "_External"
    if (Get-Command $CommandName -ea silent) {
        [string]$Message = & $Command @Splat
        $Message
    }
}
function New-TrackGpoTicket {
    param(
        [ValidateSet("Add", "Remove", "Change")]
        [parameter(Mandatory)]$Type,
        [parameter(Mandatory)]$GpoInfo,
        $Diff,
        $Stats
    )
    $Splat = @{
        Type    = $Type
        GpoInfo = $GpoInfo
    }
    if ($Diff) { $Splat.Add("Diff", $Diff) }
    if ($Stats) { $Splat.Add("Stats", $Stats) }

    $CommandName = $MyInvocation.InvocationName + "_External"
    if (Get-Command $CommandName -ea silent) {
        [string]$Message = & $Command @Splat
        $Message
    }
}
function Remove-HtmlContent {
    param([System.String[]] $html)
    # Adapted from: http://winstonfassett.com/blog/2010/09/21/html-to-text-conversion-in-powershell/
    # This function makes use of the single line (?s) regex modifier to make . apply to newlines
    # This function makes use of the multiline (?m) regex modifier to make ^|$ apply to newlines

    # Want to preserve line breaks for pretty formatting later, but need a single string with only newlines:
    $html = $html -replace "`r" -join "`n"

    # remove invisible content
    @('head', 'script', 'style', 'object', 'embed', 'applet', 'noframes', 'noscript', 'noembed') | ForEach-Object {
        $html = $html -replace "(?ms)<$_[^>]*?>.*?^</$_>", ""
    }
    # write-verbose "removed invisible blocks: `n`n$html`n"

    # Condense extra whitespace
    $html = $html -replace "( )+", " "
    # write-verbose "condensed whitespace: `n`n$html`n"
    # Remove the window styles
    $html = $html -replace '(?ms)<div id="explainText_windowStyles.*?</div>'

    # Add line breaks
    @('div', 'p', 'blockquote', 'h[1-9]', 'tr') | ForEach-Object { $html = $html -replace "(?ms)</?$_[^>]*?>.*?</$_>", ("`n" + '$0' ) }
    # Add line breaks for self-closing tags
    @('div', 'p', 'blockquote', 'h[1-9]', 'br') | ForEach-Object { $html = $html -replace "(?ms)<$_[^>]*?/>", ('$0' + "`n") }
    # write-verbose "added line breaks: `n`n$html`n"

    # table cells deserve a tab after them
    $html = $html -replace "</td>|</th>", " `t"

    #strip tags
    $html = $html -replace "<[^>]*?>", ""
    # write-verbose "removed tags: `n`n$html`n"

    # replace common entities
    @(
        @("&nbsp;", " "),
        @("&amp;bull;", " * "),
        @("&amp;lsaquo;", "<"),
        @("&amp;rsaquo;", ">"),
        @("&amp;(rsquo|lsquo|#39|#039);", "'"),
        @("&#0?39;", "'"),
        @("&amp;(quot|ldquo|rdquo);", '"'),
        @("&amp;trade;", "(tm)"),
        @("&amp;frasl;", "/"),
        @("&amp;(quot|#34|#034|#x22);", '"'),
        @('&amp;(amp|#38|#038|#x26);', "&amp;"),
        @("&amp;(lt|#60|#060|#x3c);", "<"),
        @("&amp;(gt|#62|#062|#x3e);", ">"),
        @('&amp;(copy|#169);', "(c)"),
        @("&amp;(reg|#174);", "(r)"),
        @("&amp;nbsp;", " "),
        @("&amp;(.{2,6});", "")
    ) | ForEach-Object { $html = $html -replace $_[0], $_[1] }
    # write-verbose "replaced entities: `n`n$html`n"

    # Extra lines should get condensed
    $html = $html -replace "`n+", "`n"
    $html -split "`n"

}
function Resolve-Path_Force {
    <#
    .SYNOPSIS
        Calls Resolve-Path but works for files that don't exist.
    .REMARKS
        From http://devhawk.net/blog/2010/1/22/fixing-powershells-busted-resolve-path-cmdlet
    #>

    param (
        [string] $FileName
    )

    $FileName = Resolve-Path $FileName -ErrorAction SilentlyContinue -ErrorVariable _frperror
    if (-not($FileName)) {
        $FileName = $_frperror[0].TargetObject
    }

    return $FileName
}