Get-CMUnusedSources.ps1

<#PSScriptInfo
.VERSION 1.0.8
.GUID 62980d1d-d263-4c01-b49c-e64502363127
.AUTHOR Adam Cook (Twitter: @codaamok - website: cookadam.co.uk)
.COMPANYNAME
.COPYRIGHT
.TAGS SCCM ConfigMgr ConfigurationManager MEMCM MECM
.LICENSEURI https://github.com/codaamok/Get-CMUnusedSources/blob/master/LICENSE
.PROJECTURI https://github.com/codaamok/Get-CMUnusedSources
.ICONURI
.EXTERNALMODULEDEPENDENCIES ImportExcel
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES
#>


<#
.SYNOPSIS
    Get-CMUnusedSources will tell you what folders are not used by ConfigMgr in a given path. See https://github.com/codaamok/Get-CMUnusedSources for documentation.
 
.DESCRIPTION
    This script will tell you what folders are used or not used in a given path by a site in System Center Configuration Manager.
 
    It leverages the ConfigMgr PowerShell cmdlets to gather content object source path information so you need the console installed. The script can be run remotely from a site server, e.g. your desktop.
 
    A PSObject is returned with the UsedBy status of each folder under the given parameter -SourcesLocation. You can also specify -ExcelReport generated by the module ImportExcel, from there you can export to PDF, CSV or XLSX.
 
    See the GitHub repository README for the documentation https://github.com/codaamok/Get-CMUnusedSources and my blog with a personal account of me writing this script https://www.cookadam.co.uk/get-cmunusedsources.
 
.PARAMETER SourcesLocation
    The path to the directory you store your ConfigMgr sources. Can be a UNC or local path. Must be a valid path that you have read access to.
 
.PARAMETER SiteServer
    The site server of the given ConfigMgr site code. The server must be reachable over a network.
 
.PARAMETER SiteCode
    The site code of the ConfigMgr site you wish to query for content objects.
 
.PARAMETER ExcludeFolders
    An array of folders that you want to exclude the script from checking, which should be absolute paths under the path given for -SourcesLocation.
 
.PARAMETER Packages
    Specify this switch to include Packages within the search to determine unused content on disk.
 
.PARAMETER Applications
    Specify this switch to include Applications within the search to determine unused content on disk.
 
.PARAMETER Drivers
    Specify this switch to include Drivers within the search to determine unused content on disk.
 
.PARAMETER DriverPackages
    Specify this switch to include DriverPackages within the search to determine unused content on disk.
 
.PARAMETER OSImages
    Specify this switch to include OSImages within the search to determine unused content on disk.
 
.PARAMETER OSUpgradeImages
    Specify this switch to include OSUpgradeImages within the search to determine unused content on disk.
 
.PARAMETER BootImages
    Specify this switch to include BootImages within the search to determine unused content on disk.
 
.PARAMETER DeploymentPackages
    Specify this switch to include DeploymentPackages within the search to determine unused content on disk.
 
.PARAMETER AltFolderSearch
    Specify this if you suspect there are issue with the default mechanism of gathering folders, which is:
        Get-ChildItem -LiteralPath "\\?\UNC\server\share\folder" -Directory -Recurse | Select-Object -ExpandProperty FullName
 
.PARAMETER NoProgress
    Specify this to disable use of Write-Progress.
 
.PARAMETER Log
    Specify this to enable logging. The log file(s) will be saved to the same directory as this script with a name of <scriptname>_<datetime>.log. Rolled log files will follow a naming convention of <filename>_1.lo_ where the int increases for each rotation. Each maximum log file is 2MB.
 
.PARAMETER ExportReturnObject
    Specify this option if you wish to export the PowerShell return object to an XML file. The XML file be saved to the same directory as this script with a name of <scriptname>_<datetime>_result.xml. It can easily be reimported using Import-Clixml cmdlet.
 
.PARAMETER ExportCMContentObjects
    Specify this option if you wish to export all ConfigMgr content objects to an XML file. The XML file be saved to the same directory as this script with a name of <scriptname>_<datetime>_cmobjects.xml. It can easily be reimported using Import-Clixml cmdlet.
 
.PARAMETER ExcelReport
    Specify this option to enable the generation for an Excel report of the result. Doing this will force you to have the ImportExcel module installed. For more information on ImportExcel: https://github.com/dfinke/ImportExcel. The .xlsx file will be saved to the same directory as this script with a name of <scriptname>_<datetime>.xlsx.
 
.PARAMETER Threads
    Set the number of threads you wish to use for concurrent processing of this script. Default value is number of processes from env var NUMBER_OF_PROCESSORS.
 
.INPUTS
 
.OUTPUTS
 
.EXAMPLE
    C:\> $result = .\Get-CMUnusedSources.ps1 -SourcesLocation \\sccm\Applications$ -SiteServer SCCM -Applications -Log -LogFileSize 10MB -NumOfRotatedLogs 5 -ExportReturnObject -ExcelReport -Threads 2
 
.EXAMPLE
    C:\> $result = .\Get-CMUnusedSources.ps1 -SourcesLocation F:\ -SiteServer SCCM -Log -ExcelReport
 
.NOTES
    Author: Adam Cook (@codaamok)
    License: GLP-3.0
    Source: https://github.com/codaamok/Get-CMUnusedSources
#>

#Requires -Version 5.1
Param (
    [Parameter(Mandatory=$true, Position = 0, HelpMessage="Valid path (local or remote0 to where you store you ConfigMgr sources.")]
    [ValidateScript({
        if (!([System.IO.Directory]::Exists($_))) {
            throw "Invalid path or access denied"
        } elseif (!($_ | Test-Path -PathType Container)) {
            throw "Value must be a directory, not a file"
        } else {
            return $true
        }
    })]
    [string]$SourcesLocation,
    [Parameter(Mandatory=$true, Position = 1, HelpMessage="ConfigMgr site server of the site site code.")]
    [ValidateScript({
        if (!(Test-Connection -ComputerName $_ -Count 1 -ErrorAction SilentlyContinue)) {
            throw "Host `"$($_)`" is unreachable"
        } else {
            return $true
        }
    })]
    [string]$SiteServer,
    [Parameter(Mandatory=$false, Position = 2, HelpMessage="ConfigMgr site code you are querying.")]
    [ValidatePattern('^[a-zA-Z0-9]{3}$')]
    [string]$SiteCode,
    [Parameter(Mandatory=$false, HelpMessage="An array of folders to exclude under -SourcesLocation.")]
    [string[]]$ExcludeFolders,
    [Parameter(Mandatory=$false, HelpMessage="Gather packages.")]
    [switch]$Packages,
    [Parameter(Mandatory=$false, HelpMessage="Gather applications.")]
    [switch]$Applications,
    [Parameter(Mandatory=$false, HelpMessage="Gather drivers.")]
    [switch]$Drivers,
    [Parameter(Mandatory=$false, HelpMessage="Gather driver packages.")]
    [switch]$DriverPackages,
    [Parameter(Mandatory=$false, HelpMessage="Gather Operating System images.")]
    [switch]$OSImages,
    [Parameter(Mandatory=$false, HelpMessage="Gather Operating System upgrade images.")]
    [switch]$OSUpgradeImages,
    [Parameter(Mandatory=$false, HelpMessage="Gather boot images.")]
    [switch]$BootImages,
    [Parameter(Mandatory=$false, HelpMessage="Gather deployment packages.")]
    [switch]$DeploymentPackages,
    [Parameter(Mandatory=$false, HelpMessage="Enable alternative folder search.")]
    [switch]$AltFolderSearch,
    [Parameter(Mandatory=$false, HelpMessage="Disable use of Write-Progress.")]
    [switch]$NoProgress,
    [Parameter(Mandatory=$false, HelpMessage="Enable logging.")]
    [switch]$Log,
    [Parameter(Mandatory=$false, HelpMessage="Generate XML export of PowerShell object with the result.")]
    [switch]$ExportReturnObject,
    [Parameter(Mandatory=$false, HelpMessage="Generate XML export of PowerShell object with all ConfigMgr content objects.")]
    [switch]$ExportCMContentObjects,
    [Parameter(Mandatory=$false, HelpMessage="Generate Excel report of the result.")]
    [switch]$ExcelReport,
    [Parameter(Mandatory=$false, HelpMessage="Number of threads to use for execution.")]
    [int32]$Threads = $env:NUMBER_OF_PROCESSORS
)

<#
TODO:
        - $SiteServer should be validated - omg stupid hard
        - Exclude folders parameter in get-childitem?
        - publish to technet
        - delete log entries for $result??
        - if given F:\ or \\server\f$ currently Get-AllPaths does not determine shared folders that match the path used
#>


#region Define PSDefaultParameterValues and other variables
$JobId = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss'
$StartTime = Get-Date

$PSDefaultParameterValues = @{
    "Write-CMLogEntry:Bias"                 = (Get-CimInstance -ClassName Win32_TimeZone | Select-Object -ExpandProperty Bias)
    "Write-CMLogEntry:Folder"               = ($PSCommandPath | Split-Path -Parent)
    "Write-CMLogEntry:FileName"             = (($PSCommandPath | Split-Path -Leaf) + "_" + $JobId + ".log")
    "Write-CMLogEntry:Enable"               = $Log.IsPresent
    "Write-CMLogEntry:MaxLogFileSize"       = 2MB
    "Write-CMLogEntry:MaxNumOfRotatedLogs"  = 0
    "Write-ScreenInfo:ScriptStart"          = $StartTime
    "Export-Excel:TableStyle"               = "Medium20"
    "Add-ExcelTable:TableStyle"             = "Medium20"
    "Export-Excel:AutoSize"                 = $true
    "Export-Excel:AutoFilter"               = $true
    "Export-Excel:ErrorACtion"              = "Stop"
    "Add-Worksheet:ErrorACtion"             = "Stop"
    "Set-ExcelRange:ErrorACtion"            = "Stop"
    "Remove-Worksheet:ErrorACtion"          = "Stop"
    "Close-ExcelPackage:ErrorACtion"        = "Stop"
    "Export-Excel:ErrorVariable"            = "NewExcelReportErr"
    "Add-Worksheet:ErrorVariable"           = "NewExcelReportErr"
    "Set-ExcelRange:ErrorVariable"          = "NewExcelReportErr"
    "Remove-Worksheet:ErrorVariable"        = "NewExcelReportErr"
    "Close-ExcelPackage:ErrorVariable"      = "NewExcelReportErr"
}
#endregion

#region Define functions
Function Write-CMLogEntry {
    <#
    .SYNOPSIS
    Write to log file in CMTrace friendly format.
    .DESCRIPTION
    Half of the code in this function is Cody Mathis's. I added log rotation and some other bits, with help of Chris Dent for some sorting and regex. Should find this code on the WinAdmins GitHub repo for configmgr.
    .OUTPUTS
    Writes to $Folder\$FileName and/or standard output.
    .LINK
    https://github.com/winadminsdotorg/SystemCenterConfigMgr
    #>

    param (
        [parameter(Mandatory = $true, HelpMessage = 'Value added to the log file.')]
        [ValidateNotNullOrEmpty()]
        [string]$Value,
        [parameter(Mandatory = $false, HelpMessage = 'Severity for the log entry. 1 for Informational, 2 for Warning and 3 for Error.')]
        [ValidateNotNullOrEmpty()]
        [ValidateSet('1', '2', '3')]
        [string]$Severity = 1,
        [parameter(Mandatory = $false, HelpMessage = "Stage that the log entry is occuring in, log refers to as 'component'.")]
        [ValidateNotNullOrEmpty()]
        [string]$Component,
        [parameter(Mandatory = $true, HelpMessage = 'Name of the log file that the entry will written to.')]
        [ValidateNotNullOrEmpty()]
        [string]$FileName,
        [parameter(Mandatory = $true, HelpMessage = 'Path to the folder where the log will be stored.')]
        [ValidateNotNullOrEmpty()]
        [string]$Folder,
        [parameter(Mandatory = $false, HelpMessage = 'Set timezone Bias to ensure timestamps are accurate.')]
        [ValidateNotNullOrEmpty()]
        [int32]$Bias,
        [parameter(Mandatory = $false, HelpMessage = 'Maximum size of log file before it rolls over. Set to 0 to disable log rotation.')]
        [ValidateNotNullOrEmpty()]
        [int32]$MaxLogFileSize = 0,
        [parameter(Mandatory = $false, HelpMessage = 'Maximum number of rotated log files to keep. Set to 0 for unlimited rotated log files.')]
        [ValidateNotNullOrEmpty()]
        [int32]$MaxNumOfRotatedLogs = 0,
        [parameter(Mandatory = $true, HelpMessage = 'A switch that enables the use of this function.')]
        [ValidateNotNullOrEmpty()]
        [switch]$Enable
    )

    if ($Enable.IsPresent -eq $true) {
        # Determine log file location
        $LogFilePath = Join-Path -Path $Folder -ChildPath $FileName

        if ((([System.IO.FileInfo]$LogFilePath).Exists) -And ($MaxLogFileSize -ne 0)) {

            # Get log size in bytes
            $LogFileSize = [System.IO.FileInfo]$LogFilePath | Select-Object -ExpandProperty Length

            if ($LogFileSize -ge $MaxLogFileSize) {

                # Get log file name without extension
                $LogFileNameWithoutExt = $FileName -replace ([System.IO.Path]::GetExtension($FileName))

                # Get already rolled over logs
                $RolledLogs = "{0}_*" -f $LogFileNameWithoutExt
                $AllLogs = Get-ChildItem -Path $Folder -Name $RolledLogs -File

                # Sort them numerically (so the oldest is first in the list)
                $AllLogs = $AllLogs | Sort-Object -Descending { $_ -replace '_\d+\.lo_$' }, { [Int]($_ -replace '^.+\d_|\.lo_$') }
            
                ForEach ($Log in $AllLogs) {
                    # Get log number
                    $LogFileNumber = [int32][Regex]::Matches($Log, "_([0-9]+)\.lo_$").Groups[1].Value
                    switch (($LogFileNumber -eq $MaxNumOfRotatedLogs) -And ($MaxNumOfRotatedLogs -ne 0)) {
                        $true {
                            # Delete log if it breaches $MaxNumOfRotatedLogs parameter value
                            $DeleteLog = Join-Path $Folder -ChildPath $Log
                            [System.IO.File]::Delete($DeleteLog)
                        }
                        $false {
                            # Rename log to +1
                            $Source = Join-Path -Path $Folder -ChildPath $Log
                            $NewFileName = $Log -replace "_([0-9]+)\.lo_$",("_{0}.lo_" -f ($LogFileNumber+1))
                            $Destination = Join-Path -Path $Folder -ChildPath $NewFileName
                            [System.IO.File]::Copy($Source, $Destination, $true)
                        }
                    }
                }

                # Copy main log to _1.lo_
                $NewFileName = "{0}_1.lo_" -f $LogFileNameWithoutExt
                $Destination = Join-Path -Path $Folder -ChildPath $NewFileName
                [System.IO.File]::Copy($LogFilePath, $Destination, $true)

                # Blank the main log
                $StreamWriter = [System.IO.StreamWriter]::new($LogFilePath, $false)
                $StreamWriter.Close()
            }
        }

        # Construct time stamp for log entry
        switch -regex ($Bias) {
            '-' {
                $Time = [string]::Concat($(Get-Date -Format 'HH:mm:ss.fff'), $Bias)
            }
            Default {
                $Time = [string]::Concat($(Get-Date -Format 'HH:mm:ss.fff'), '+', $Bias)
            }
        }

        # Construct date for log entry
        $Date = (Get-Date -Format 'MM-dd-yyyy')
    
        # Construct context for log entry
        $Context = $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)
    
        # Construct final log entry
        $LogText = [string]::Format('<![LOG[{0}]LOG]!><time="{1}" date="{2}" component="{3}" context="{4}" type="{5}" thread="{6}" file="">', $Value, $Time, $Date, $Component, $Context, $Severity, $PID)
    
        # Add value to log file
        try {
            $StreamWriter = [System.IO.StreamWriter]::new($LogFilePath, 'Append')
            $StreamWriter.WriteLine($LogText)
            $StreamWriter.Close()
        }
        catch [System.Exception] {
            Write-Warning -Message ("Unable to append log entry to {0} file. Error message: {1}" -f $FileName, $_.Exception.Message)
        }
    }
}

Function Write-ScreenInfo {
    [CmdletBinding()]
    <#
    .SYNOPSIS
        Inspired by PSLog in the AutomatedLab module
        https://github.com/AutomatedLab/AutomatedLab/blob/c01e2458e38811ccc4b2c58e3f958d666c39d9b9/PSLog/PSLog.psm1
    #>

    Param(
        [Parameter(Mandatory = $true, Position = 0)]
        [string[]]$Message,
        [Parameter(Mandatory = $true)]
        [datetime]$ScriptStart,
        [Parameter(Mandatory = $false)]
        [ValidateSet("Error", "Warning", "Info", "Verbose", "Debug")]
        [string]$Type = "Info",
        [Parameter(Mandatory = $false)]
        [int32]$Indent = 0
    )

    $Date = Get-Date
    $TimeString = "{0:d2}:{1:d2}:{2:d2}" -f $Date.Hour, $Date.Minute, $Date.Second
    $TimeDelta = $Date - $ScriptStart
    $TimeDeltaString = "{0:d2}:{1:d2}:{2:d2}" -f $TimeDelta.Hours, $TimeDelta.Minutes, $TimeDelta.Seconds

    ForEach ($Msg in $Message) {
        $Msg = ("- " + $Msg).PadLeft(($Msg.Length) + ($Indent * 4), " ")
        $string = "[ {0} | {1} ] {2}" -f $TimeString, $TimeDeltaString, $Msg
        switch ($Type) {
            "Error" {
                Write-Host $string -ForegroundColor Red
            }
            "Warning" {
                Write-Host $string -ForegroundColor Yellow
            }
            "Info" {
                Write-Host $string
            }
            "Debug" {
                if ($DebugPreference -eq "Continue") { Write-Host $string -ForegroundColor Cyan }
            }
            "Verbose" {
                if ($VerbosePreference -eq "Continue") { Write-Host $string -ForegroundColor Cyan }
            }
        }
    }
}

Function Get-CMContent {
    [CmdletBinding()]
    <#
    .SYNOPSIS
    Get all ConfigMgr objects that can hold content, i.e. content objects.
    .DESCRIPTION
    Using the ConfigMgr PoSH cmdlets, in the $Commands array, get all content objects and filter them to the given site code.
    For each content object, create a PSCustomObject with the needed properties.
    Called by main body.
    .OUTPUTS
    System.Object.PSCustomObject
    #>

    Param(
        [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
        [string[]]$Commands,
        [Parameter(Mandatory=$true,ValueFromPipeline=$false)]
        [string]$SiteServer,
        [Parameter(Mandatory=$true,ValueFromPipeline=$false)]
        [string]$SiteCode
    )
    Begin {
        $CMPSSuppressFastNotUsedCheck = $true
        [hashtable]$ShareCache = @{}
    }
    Process {
        ForEach ($Command in $Commands) {

            Write-CMLogEntry -Value ("Gathering: {0}" -f $Command -replace "Get-CM") -Severity 1 -Component "GatherContentObjects"

            # Filter by site code
            $Command = $Command + " | Where-Object SourceSite -eq `"{0}`"" -f $SiteCode

            ForEach ($item in (Invoke-Expression $Command)) {
                switch -regex ($Command) {
                    "^Get-CMApplication.+" {
                        $AppMgmt = [xml]$item.SDMPackageXML | Select-Object -ExpandProperty AppMgmtDigest
                        $Retired = $item | Select-Object -ExpandProperty IsExpired
                        ForEach ($DeploymentType in $AppMgmt.DeploymentType) {
                            $SourcePaths = $DeploymentType.Installer.Contents.Content.Location
                            # Using ForEach-Object because even if $SourcePaths is null, it will iterate null once which is ideal here where deployment types can have no source path.
                            # Also, A deployment type can have more than 1 source path: for install and uninstall paths
                            $SourcePaths | ForEach-Object {
                                $SourcePath = $_

                                # Get every possible path
                                $GetAllPathsResult = Get-AllPaths -Path $SourcePath -Cache $ShareCache -SiteServer $SiteServer

                                # Create content object PSObject with needed properties and add to array
                                $obj = [ordered]@{
                                    ContentType     = "Application"
                                    UniqueID        = $DeploymentType | Select-Object -ExpandProperty LogicalName
                                    Name            = "{0}::{1}" -f $item.LocalizedDisplayName,$DeploymentType.Title.InnerText
                                    IsRetired       = $Retired
                                    SourcePath      = $SourcePath
                                    SourcePathFlag  = [int](Test-FileSystemAccess -Path $SourcePath -Rights Read)
                                    AllPaths        = $GetAllPathsResult[1]
                                }
                                if ($obj["SourcePathFlag"] -eq 0) {
                                    $obj.Add("SizeMB", (Measure-ChildItem -Path $obj["SourcePath"] -Unit MB -Digits 2 | Select-Object -ExpandProperty Size))
                                }
                                else {
                                    $obj.Add("SizeMB", $null)
                                }
                                [PSCustomObject]$obj
                            }

                            # Maintaining cache of shared folders for servers encountered so far
                            $ShareCache = $GetAllPathsResult[0]

                            Write-CMLogEntry -Value ("{0} - {1} - {2} - {3} - {4} - {5} - {6}" -f $obj.ContentType,$obj.UniqueID,$obj.Name,$obj.SourcePath,$obj.SourcePathFlag,[String]::Join(", ", @($obj.AllPaths.Keys)),$obj.SizeMB) -Severity 1 -Component "GatherContentObjects"
                        }
                    }
                    "^Get-CMDriver\s.+" { 
                        $SourcePath = $item.ContentSourcePath
                        # Get every possible path
                        $GetAllPathsResult = Get-AllPaths -Path $SourcePath -Cache $ShareCache -SiteServer $SiteServer 
                        
                        # Create content object PSObject with needed properties and add to array
                        $obj = [ordered]@{
                            ContentType     = "Driver"
                            UniqueID        = $item.CI_ID
                            Name            = $item.LocalizedDisplayName
                            IsRetired       = "n/a"
                            SourcePath      = $SourcePath
                            SourcePathFlag  = [int](Test-FileSystemAccess -Path $SourcePath -Rights Read)
                            AllPaths        = $GetAllPathsResult[1]
                        }
                        if ($obj["SourcePathFlag"] -eq 0) {
                            $obj.Add("SizeMB", (Measure-ChildItem -Path $obj["SourcePath"] -Unit MB -Digits 2 | Select-Object -ExpandProperty Size))
                        }
                        else {
                            $obj.Add("SizeMB", $null)
                        }
                        [PSCustomObject]$obj

                        # Maintaining cache of shared folders for servers encountered so far
                        $ShareCache = $GetAllPathsResult[0]

                        Write-CMLogEntry -Value ("{0} - {1} - {2} - {3} - {4} - {5} - {6}" -f $obj.ContentType,$obj.UniqueID,$obj.Name,$obj.SourcePath,$obj.SourcePathFlag,[String]::Join(", ", @($obj.AllPaths.Keys)),$obj.SizeMB) -Severity 1 -Component "GatherContentObjects"
                    }
                    default {
                        # OS images and boot iamges are absolute paths to files
                        if (($Command -match ("^Get-CMOperatingSystemImage.+")) -Or ($Command -match ("^Get-CMBootImage.+"))) {
                            $SourcePath = Split-Path $item.PkgSourcePath
                        }
                        else {
                            $SourcePath = $item.PkgSourcePath
                        }
                        
                        $ContentType = ([Regex]::Matches($Command, "Get-CM([^\s]+)")).Groups[1].Value
                        # Get every possible path
                        $GetAllPathsResult = Get-AllPaths -Path $SourcePath -Cache $ShareCache -SiteServer $SiteServer

                        # Create content object PSObject with needed properties and add to array
                        $obj = [ordered]@{
                            ContentType     = $ContentType
                            UniqueID        = $item.PackageId
                            Name            = $item.Name
                            IsRetired       = "n/a"
                            SourcePath      = $SourcePath
                            SourcePathFlag  = [int](Test-FileSystemAccess -Path $SourcePath -Rights Read)
                            AllPaths        = $GetAllPathsResult[1]
                        }
                        if ($obj["SourcePathFlag"] -eq 0) {
                            $obj.Add("SizeMB", (Measure-ChildItem -Path $obj["SourcePath"] -Unit MB -Digits 2 | Select-Object -ExpandProperty Size))
                        }
                        else {
                            $obj.Add("SizeMB", $null)
                        }
                        [PSCustomObject]$obj

                        # Maintaining cache of shared folders for servers encountered so far
                        $ShareCache = $GetAllPathsResult[0]

                        Write-CMLogEntry -Value ("{0} - {1} - {2} - {3} - {4} - {5} - {6}" -f $obj.ContentType,$obj.UniqueID,$obj.Name,$obj.SourcePath,$obj.SourcePathFlag,[String]::Join(", ", @($obj.AllPaths.Keys)),$obj.SizeMB) -Severity 1 -Component "GatherContentObjects"
                    }   
                }
            }
            Write-CMLogEntry -Value ("Done gathering: {0}" -f ($Command -replace "Get-CM").Split(" ")[0]) -Severity 1 -Component "GatherContentObjects"
        }
    }
}

Function Get-AllPaths {
    <#
    .SYNOPSIS
    Determine all possible paths for a given path.
    .DESCRIPTION
    For a given path, determine all other possible path combinations that ultimately point back to $Path.
    Useful to determine the local path for a given UNC path (in turn get the UNC path that uses the drive $ share), or if a there are multiple shared folders pointing to the same location.
    Called by Get-CMContent.
    .OUTPUTS
    System.Object.List with always only two elements; $AllPaths (hashtable, the calculated list of "all paths" for the given $Path), and $Cache (hashtable, the shared folders cache).
    The first element ($Cache, hashtable) of the $result collection is dedicated to being a cache which will contain a list of all servers (key) and a hashtable (value) for a list of shared folder names and their local paths.
    The second element ($AllPath, hashtable) of the $result collection contains the list of all possible paths associated with the given $Path (key) and the NetBIOS server name (value) of which it belongs to.
    .EXAMPLE
    PS C:\> Get-AllPaths -Path "\\SCCM\Applications$\7-zip" -Cache $SharedFolderCache -SiteServer "SCCM"
 
        Name Value
        ---- -----
        192.168.175.11 {Folder, UpdateServicesPackages, EasySetupPayload, SMSSIG$...}
        sccm {Folder, UpdateServicesPackages, EasySetupPayload, SMSSIG$...}
        sccm.acc.local {Folder, UpdateServicesPackages, EasySetupPayload, SMSSIG$...}
        \\192.168.175.11\Applications$ sccm
        \\192.168.175.11\F$\Applica... sccm
        \\sccm.acc.local\Applications$ sccm
        \\sccm\Applications$ sccm
        \\sccm\DiffFolder1$ sccm
        \\sccm.acc.local\F$\Applica... sccm
        \\192.168.175.11\DiffFolder1$ sccm
        F:\Applications sccm
        \\sccm.acc.local\DiffFolder1$ sccm
        \\sccm\F$\Applications sccm
    #>

    param (
        [string]$Path,
        [hashtable]$Cache,
        [string]$SiteServer
    )

    [System.Collections.Generic.List[Object]]$result = @()
    [hashtable]$AllPaths = @{}

    if (([string]::IsNullOrEmpty($Path) -eq $false) -And ($Path -notmatch "^[a-zA-Z]:\\$")) {
        $Path = $Path.TrimEnd("\")
    }

    ##### Determine path type

    switch ($true) {
        ($Path -match "^\\\\([a-zA-Z0-9`~!@#$%^&(){}\'._-]+)\\([a-zA-Z]\$)$") {
            # Path that is \\server\f$
            $Server,$ShareName,$ShareRemainder = $Matches[1],$Matches[2],$null
            $PathType = 4
            break
        }
        ($Path -match "^\\\\([a-zA-Z0-9`~!@#$%^&(){}\'._-]+)\\([a-zA-Z]\$)(\\[a-zA-Z0-9`~\\!@#$%^&(){}\'._ -]+)") {
            # Path that is \\server\f$\folder
            $Server,$ShareName,$ShareRemainder = $Matches[1],$Matches[2],$Matches[3]
            $PathType = 3
            break
        }
        
        ($Path -match "^\\\\([a-zA-Z0-9`~!@#$%^&(){}\'._-]+)\\([a-zA-Z0-9`~!@#$%^&(){}\'._ -]+)$") {
            # Path that is \\server\share
            $Server,$ShareName,$ShareRemainder = $Matches[1],$Matches[2],$null
            $PathType = 2
            break
        }
        ($Path -match "^\\\\([a-zA-Z0-9`~!@#$%^&(){}\'._-]+)\\([a-zA-Z0-9`~!@#$%^&(){}\'._ -]+)(\\[a-zA-Z0-9`~\\!@#$%^&(){}\'._ -]+)") {
            # Path that is \\server\share\folder
            $Server,$ShareName,$ShareRemainder = $Matches[1],$Matches[2],$Matches[3]
            $PathType = 1
            break
        }
        ($Path -match "^[a-zA-Z]:\\") {
            # Path that is local
            # Script does not determine UNC / shared folder paths if the content object source path is a local path
            $AllPaths.Add($Path, $SiteServer)
            $result.Add($Cache)
            $result.Add($AllPaths)
            return $result
        }
        ([string]::IsNullOrEmpty($Path) -eq $true) {
            # If there is no source path, just return now with $AllPaths empty
            $result.Add($Cache)
            $result.Add($AllPaths)
            return $result
        }
        default { 
            # Please share $Path with me if this is caught!
            # As a fail safe, abort
            $Message = "Unable to interpret path `"{0}`"" -f $Path
            Write-ScreenInfo -Message $Message -Type "Warning" -Indent 1
            Write-CMLogEntry -Value $Message -Severity 2 -Component "GatherContentObjects"
            $AllPaths.Add($Path, $null)
            $result.Add($Cache)
            $result.Add($AllPaths)
            return $result
        }
    }

    ##### Determine FQDN, IP and NetBIOS

    # Only determine if you have a record
    # Might be annoying if $Server is an IP, unreachable and revese lookup succeeds
    if (Test-Connection -ComputerName $Server -Count 1 -ErrorAction SilentlyContinue) {
        if ($Server -as [IPAddress]) {
            try {
                # Reverse lookup
                $FQDN = [System.Net.Dns]::GetHostEntry($Server) | Select-Object -ExpandProperty HostName
                $NetBIOS = $FQDN.Split(".")[0]
            }
            catch {
                # In case no record
                $FQDN = $null
            }
            $IP = $Server
        }
        else {
            try {
                # Get FQDN even if $Server is FQDN, so we cut out $NetBIOS and resolve for $IP
                $FQDN = [System.Net.Dns]::GetHostByName($Server) | Select-Object -ExpandProperty HostName
                $NetBIOS = $FQDN.Split(".")[0]
            }
            catch {
                # In case no record
                $FQDN = $null
            }
            $IP = (((Test-Connection $Server -Count 1 -ErrorAction SilentlyContinue)).IPV4Address).IPAddressToString
        }
    }
    else {
        # Won't be able to query Win32_Class if unreachable so no point continuing
        Write-CMLogEntry -Value ("Server `"{0}`" is unreachable" -f $Server) -Severity 2 -Component "GatherContentObjects"
        $AllPaths.Add($Path, $null)
        $result.Add($Cache)
        $result.Add($AllPaths)
        return $result
    }

    ##### Update the cache of shared folders and their local paths

    if (($Cache.Keys -contains $FQDN) -eq $false) {
        # Do not yet have this server's shares cached
        # $AllSharedFolders is null if couldn't connect to serverr to get all shared folders
        $AllSharedFolders = Get-AllSharedFolders -Server $FQDN
        If ($AllSharedFolders -is [Hashtable] -And ($AllSharedFolders.count -gt 0)) {
            $NetBIOS,$FQDN,$IP | ForEach-Object {
                if ([String]::IsNullOrEmpty($_) -eq $true) {
                    continue
                }
                else {
                    $Cache.Add($_, $AllSharedFolders)
                }
            }
        }
        else {
            # Add null so on the next encounter of a server from a given UNC path, we don't wastefully try again
            $NetBIOS,$FQDN,$IP | ForEach-Object {
                if ([String]::IsNullOrEmpty($_) -eq $true) {
                    continue
                }
                else {
                    $Cache.Add($_, $null)
                }
            }
        }
    }

    ##### Build the AllPaths property

    [System.Collections.Generic.List[String]]$AllPathsArr = @()

    $NetBIOS,$FQDN,$IP | ForEach-Object -Process {
        if ([String]::IsNullOrEmpty($_) -eq $true) {
            continue
        }
        else {
            $AltServer = $_
            if ($null -ne $Cache.$AltServer) {
                # Get the share's local path
                $LocalPath = $Cache.$AltServer[$ShareName]
            }
            else {
                $LocalPath = $null
            }
            # If \\server\f$ then $LocalPath is "F:"
            if ([string]::IsNullOrEmpty($LocalPath) -eq $false) {
                if ($PathType -match "1|2") {
                    # Add \\server\f$\path\to\shared\folder\on\disk
                    $AllPathsArr.Add(("\\$($AltServer)\$($LocalPath)$($ShareRemainder)" -replace ':', '$'))
                    # Get other shared folders that point to the same path and add them to the AllPaths array
                    $SharesWithSamePath = $Cache.$AltServer.GetEnumerator().Where( { $_.Value -eq $LocalPath } ) | Select-Object -ExpandProperty Key
                    ForEach ($AltShareName in $SharesWithSamePath) {
                        $AllPathsArr.Add("\\$($AltServer)\$($AltShareName)$($ShareRemainder)")
                    }
                }  
            }
            else {
                Write-CMLogEntry -Value ("Could not resolve share `"{0}`" on `"{1}`" from cache, either because it does not exist or could not query Win32_Share on server" -f $ShareName,$_) -Severity 2 -Component "GatherContentObjects"
            }
            # Add the original path again but with the alternate server (FQDN / NetBIOS / IP)
            $AllPathsArr.Add("\\$($AltServer)\$($ShareName)$($ShareRemainder)")
        }
    } -End {
        if ([string]::IsNullOrEmpty($LocalPath) -eq $false) {
            # Either of the below are important in case user is running local to site server and gave local path as $SourcesLocation
            if (($LocalPath -match "^[a-zA-Z]:$") -And ($PathType -match "2|4")) {
                # Match if just a drive letter (WHY?!) and add it to AllPaths array
                # This occurs if path type is 2 and the share points to root of a volume
                $AllPathsArr.Add("$($LocalPath)\")
            }
            else {
                # Add the local path to AllPaths array
                $AllPathsArr.Add("$($LocalPath)$($ShareRemainder)")
            }
        }
    }

    # Add all that's inside the AllPaths array to the AllPaths hashtable
    # Unfotunately adding stuff to hash table that already exists in there can be noisy to stderr in console
    ForEach ($item in $AllPathsArr) {
        if (($AllPaths.Keys -notcontains $item) -eq $true) {
            $AllPaths.Add($item, $NetBIOS)
        }
    }

    $result.Add($Cache)
    $result.Add($AllPaths)
    return $result
}

Function Get-AllSharedFolders {
    <#
    .SYNOPSIS
    Get all shared folders hosted on a server.
    .DESCRIPTION
    Query Win32_Share WMI class on $Server and return a hashtable result.
    Called by Get-AllPaths.
    .OUTPUTS
    System.Object.Hashtable where a list of shared folder names (key) and their local paths (value).
    #>

    Param([String]$Server)

    [hashtable]$AllShares = @{}

    $GetCimInstanceSplat = @{
        ClassName       = "Win32_Share"
        ErrorAction     = "Stop"
        ErrorVariable   = "GetCimInstanceErr"
    }

    # Get-CimInstance uses WinRM if ComputerName is provided, which can throw access denied if console is elevated
    # I would rather the below be in place rather than unnecessarily requiring elevated console
    if ([System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName -ne $Server) {
        $GetCimInstanceSplat.Add("ComputerName", $Server)
    }

    try {
        $Shares = (Get-CimInstance @GetCimInstanceSplat).Where( {-not [string]::IsNullOrEmpty($_.Path)} )
        ForEach ($Share in $Shares) {
            # The TrimEnd method is only really concerned for drive letter shares
            # as they're usually stored as f$ = "F:\" and this messes up Get-AllPaths a little
            $AllShares.Add($Share.Name, $Share.Path.TrimEnd("\"))
        }
    }
    catch {
        $Message = "Could not query Win32_Share on `"{0}`" ({1})" -f $Server, $GetCimInstanceErr.Message
        Write-ScreenInfo -Message $Message -Type "Warning" -Indent 1
        Write-CMLogEntry -Value $Message -Severity 2 -Component "GatherContentObjects"
        $AllShares = $null
    }

    return $AllShares
}

Function Convert-UNCPath {
    <#
    .SYNOPSIS
        Prefix the path the user gives us with \\?\ to avoid the 260 MAX_PATH limit
        More info https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file#maximum-path-length-limitation
    #>

    Param(
        [string[]]$Path
    )
    ForEach ($item in $Path) {
        switch -regex ($item) {
            "^\\\\[a-zA-Z0-9`~!@#$%^&(){}\'._-]+\\[a-zA-Z0-9\\`~!@#$%^&(){}\'._ -]+" {
                # Matches if it's a UNC path
                # Could have queried .IsUnc property on [System.Uri] object but I wanted to verify user hadn't first given us \\?\ path type
                $item -replace "^\\\\", "\\?\UNC\"
                break
            }
            "^[a-zA-Z]:\\" { 
                # Matches if starts with drive letter
                "\\?\" + $item
                break
            }
            default {
                $Message = "Couldn't determine path type for `"{0}`" so might have problems accessing folders that breach MAX_PATH limit, quitting..." -f $item
                Write-CMLogEntry -Value $Message -Severity 2 -Component "GatherFolders"
                throw $Message
            }
        }
    }
    
}

Function Get-AllFolders {
    <#
    .SYNOPSIS
        Recrusively get all folders under $Path.
    .DESCRIPTION
        Get all folders in $Path. By default this function escapes the max path limit by prefixing $Path with the following: "\\?\UNC". This is what _mostly_ the driver for the PoSH 5.1 requirement.
        Called by main body.
    .OUTPUTS
        System.Object.Generic.List[String] of folder full names.
    #>

    Param(
        [string]$Path,
        [string[]]$ExcludeFolders,
        [bool]$UseAltFolderSearch
    )

    $Path = Convert-UNCPath -Path $Path

    $ExcludeFolders = Convert-UNCPath -Path $ExcludeFolders | ForEach-Object {
        [Regex]::Escape($_)
    }

    # Recursively get all folders
    if ($UseAltFolderSearch -eq $true -or $PSBoundParameters.ContainsKey("ExcludeFolders")) {
        # The function handles if $ExcludeFolders is null
        # Performance is better when using this style of folder recursion with exclusions / filtering right with Where-Object
        [System.Collections.Generic.List[String]]$Folders = Get-AllFoldersAlt -FolderName $Path -ExcludeFolders $ExcludeFolders
    }
    else {
        try {
            [System.Collections.Generic.List[String]]$Folders = Get-ChildItem -LiteralPath $Path -Directory -Recurse -ErrorVariable GetChildItemErr | Select-Object -ExpandProperty FullName
        }
        catch {
            $Message = "Consider using -AltFolderSearch ({0}), quiting..." -f $GetChildItemErr.Message
            Write-CMLogEntry -Value $Message -Severity 3 -Component "GatherFolders"
            throw $Message
        }
    }

    # Add root directory
    if ($Folders -is [System.Collections.Generic.List[String]] -And $Folders.count -gt 0) {
        $Folders.Add($Path)
    }
    else {
        $Folders = $Path
    }

    # Undo the \\?\ prefix
    switch ($true) {
        ($Path -match "^\\\\\?\\UNC\\") {
            # Matches if starts with \\?\UNC\
            $Folders = $Folders -replace [Regex]::Escape("\\?\UNC\"), "\\"
            break
        }
        ($Path -match "^\\\\\?\\[a-zA-Z]{1}:\\") {
            # Matches if starts with \\?\A:\ (A is just an example drive letter used)
            $Folders = $Folders -replace [Regex]::Escape("\\?\"), ""
            break
        }
        default {
            # For some reason, couldn't undo \\?\ prefix. If you get this, please share $Path with me!
            # No big deal though, can keep going. $SourcesLocation will stay as what the user gave in the parent scope
            Write-CMLogEntry -Value ("Couldn't reset {0}" -f $Path) -Severity 3 -Component "GatherFolders"
        }
    }
    
    $Folders | Sort-Object

}
Function Get-AllFoldersAlt {
    <#
    .SYNOPSIS
        Get all folders under $FolderName, but not recursively.
    .DESCRIPTION
        Get all folders under $FolderName but does not recursively get all folders for each and every child funder.
        This exists because in some environments Get-ChildItem would throw an exception "Not enough quota is available to process this command.". FullyQualifiedErrorId: "DirIOError,Microsoft.PowerShell.Commands.GetChildItemCommand".
        While investigating the exception was thrown at around the 50k size of any collection type and packet traces showed SMBv1 packets returning similar exception message as by PoSH, some sort of quota limit.
        Further testing on different storage systems using SMBv1 this exception was not reproducable.
        Massive thanks to Chris Kibble for coming up with this work around and time to help troubleshoot!
        Called by Get-AllFolders.
    #>

    Param(
        [string]$FolderName,
        [string[]]$ExcludeFolders
    )

    if ($null -eq $ExcludeFolders) {
        $Folders = Get-ChildItem -LiteralPath $FolderName -Directory | Select-Object -ExpandProperty FullName | Where-Object {
            -not [String]::IsNullOrEmpty($_)
        }
    }
    else {
        $Folders = Get-ChildItem -LiteralPath $FolderName -Directory | Select-Object -ExpandProperty FullName | Where-Object {
            -not [String]::IsNullOrEmpty($_) -And $_ -notmatch [String]::Join("|", $ExcludeFolders)
        }
    }
    
    ForEach ($Folder in $Folders) {
        Get-AllFoldersAlt -FolderName $Folder -ExcludeFolders $ExcludeFolders
    }

    return $Folders
}

Function Test-FileSystemAccess {
    <#
    .SYNOPSIS
        Check for file system access on a given folder.
    .OUTPUTS
        [System.Enum]
        ERROR_SUCCESS (0)
        ERROR_PATH_NOT_FOUND (3)
        ERROR_ACCESS_DENIED (5)
        ERROR_ELEVATION_REQUIRED (740)
    .NOTES
        Authors: Patrick Seymour / Adam Cook
        Contact: @pseymour / @codaamok
    #>

    param
    (
        [Parameter(Mandatory=$false)]
        [string]$Path,
        [Parameter(Mandatory=$true)]
        [System.Security.AccessControl.FileSystemRights]$Rights
    )

    enum FileSystemAccessState {
        ERROR_SUCCESS
        ERROR_PATH_NOT_FOUND = 3
        ERROR_ACCESS_DENIED = 5
        ERROR_ELEVATION_REQUIRED = 740
    }

    [System.Security.Principal.WindowsIdentity]$currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
    [System.Security.Principal.WindowsPrincipal]$currentPrincipal = New-Object Security.Principal.WindowsPrincipal($currentIdentity)
    $IsElevated = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
    $IsInAdministratorsGroup = $currentIdentity.Claims.Value -contains "S-1-5-32-544"

    if ([System.IO.Directory]::Exists($Path))
    {
        try
        {
            [System.Security.AccessControl.FileSystemSecurity]$security = (Get-Item -Path ("FileSystem::{0}" -f $Path) -Force).GetAccessControl()
            if ($null -ne $security)
            {
                [System.Security.AccessControl.AuthorizationRuleCollection]$rules = $security.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
                for([int]$i = 0; $i -lt $rules.Count; $i++)
                {
                    if (($currentIdentity.Groups.Contains($rules[$i].IdentityReference)) -or ($currentIdentity.User -eq $rules[$i].IdentityReference))
                    {
                        [System.Security.AccessControl.FileSystemAccessRule]$fileSystemRule = [System.Security.AccessControl.FileSystemAccessRule]$rules[$i]
                        if ($fileSystemRule.FileSystemRights.HasFlag($Rights))
                        {
                            return [FileSystemAccessState]::ERROR_SUCCESS
                        }
                    }
                }

                if (($IsElevated -eq $false) -And ($IsInAdministratorsGroup -eq $true) -And ($rules.Where( { ($_.IdentityReference -eq "S-1-5-32-544") -And ($_.FileSystemRights.HasFlag($Rights)) } )))
                {
                    # At this point we were able to read ACL and verify Administrators group access, likely because we were qualified by the object set as owner
                    return [FileSystemAccessState]::ERROR_ELEVATION_REQUIRED
                }
                else
                {
                    return [FileSystemAccessState]::ERROR_ACCESS_DENIED
                }

            }
            else
            {
                return [FileSystemAccessState]::ERROR_ACCESS_DENIED
            }
        }
        catch
        {
            return [FileSystemAccessState]::ERROR_ACCESS_DENIED
        }
    }
    else
    {
        return [FileSystemAccessState]::ERROR_PATH_NOT_FOUND
    }
}

function Measure-ChildItem {
    <#
    .SYNOPSIS
    Recursively measures the size of a directory.
    .NOTES
    Author: Chris Dent (indented-automation) https://github.com/indented-automation
    Source: https://github.com/steviecoaster/PSSysadminToolkit/blob/Dev/Public/Measure-ChildItem.ps1
    MIT license. http://www.opensource.org/licenses/MIT
    #>


    [CmdletBinding()]
    param (
        # The path to measure the size of. Accepts pipeline input. By default the size of the current working directory is measured.
        [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [String]$Path = $pwd,

        # The units sizes should be displayed in. By default, sizes are displayed in Bytes.
        [ValidateSet('B', 'KB', 'MB', 'GB', 'TB')]
        [String]$Unit = 'B',

        # When rounding, the number of digits to display after a decimal point. By defaut sizes are rounded to two decimal places.
        [ValidateRange(0, 28)]
        [Int32]$Digits = 2,

        # Return the size value only, discards file, and directory counts and path information.
        [Switch]$ValueOnly
    )

    begin {
        if (-not ('SC.IO.FileSearcher' -as [Type])) {
            Add-Type '
                using System;
                using System.Collections.Generic;
                using System.IO;
                using System.Runtime.InteropServices;
 
                namespace SC.IO
                {
                    [StructLayout(LayoutKind.Sequential)]
                    public struct FILETIME
                    {
                        public uint dwLowDateTime;
                        public uint dwHighDateTime;
                    };
 
                    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
                    public struct WIN32_FIND_DATA
                    {
                        public FileAttributes dwFileAttributes;
                        public FILETIME ftCreationTime;
                        public FILETIME ftLastAccessTime;
                        public FILETIME ftLastWriteTime;
                        public int nFileSizeHigh;
                        public int nFileSizeLow;
                        public int dwReserved0;
                        public int dwReserved1;
                        [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
                        public string cFileName;
                        [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)]
                        public string cAlternate;
                    }
 
                    public class UnsafeNativeMethods
                    {
                        [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
                        public static extern IntPtr FindFirstFile(string lpFileName, out WIN32_FIND_DATA lpFindFileData);
 
                        [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
                        public static extern IntPtr FindFirstFileExW(
                            string lpFileName,
                            int fInfoLevelId,
                            out WIN32_FIND_DATA lpFindFileData,
                            int fSearchOp,
                            IntPtr lpSearchFilter,
                            int dwAdditionalFlags
                        );
 
                        [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
                        public static extern bool FindNextFile(IntPtr hFindFile, out WIN32_FIND_DATA lpFindFileData);
 
                        [DllImport("kernel32.dll", SetLastError = true)]
                        [return: MarshalAs(UnmanagedType.Bool)]
                        public static extern bool FindClose(IntPtr hFindFile);
                    }
 
                    public class FileSearcher
                    {
                        private static uint convertToUInt(int value)
                        {
                            return BitConverter.ToUInt32(
                                BitConverter.GetBytes(value),
                                0
                            );
                        }
 
                        private static long convertToLong(int value)
                        {
                            return (long)(convertToUInt(value) << 32);
                        }
 
                        public static long[] MeasureItem(string path, bool recurse, long[] itemData)
                        {
                            if (itemData == null)
                            {
                                itemData = new long[]{ 0, 0, 0 };
                            }
 
                            string searchPath;
                            if (path.StartsWith(@"\\"))
                            {
                                searchPath = String.Format(@"\\?\UNC\{0}\*", path.Substring(2));
                            }
                            else
                            {
                                searchPath = String.Format(@"\\?\{0}\*", path);
                            }
 
                            WIN32_FIND_DATA findData = new WIN32_FIND_DATA();
                            IntPtr findHandle = UnsafeNativeMethods.FindFirstFileExW(searchPath, 1, out findData, 0, IntPtr.Zero, 0);
                            do
                            {
                                if (findData.dwFileAttributes.HasFlag(FileAttributes.Directory))
                                {
                                    if (recurse && findData.cFileName != "." && findData.cFileName != "..")
                                    {
                                        itemData[2]++;
                                        itemData = MeasureItem(
                                            Path.Combine(path, findData.cFileName),
                                            recurse,
                                            itemData
                                        );
                                    }
                                }
                                else
                                {
                                    itemData[0] += convertToLong(findData.nFileSizeHigh) + (long)convertToUInt(findData.nFileSizeLow);
                                    itemData[1]++;
                                }
                            } while (UnsafeNativeMethods.FindNextFile(findHandle, out findData));
                            UnsafeNativeMethods.FindClose(findHandle);
 
                            return itemData;
                        }
                    }
                }
            '

        }

        $power = ('B', 'KB', 'MB', 'GB', 'TB').IndexOf($Unit.ToUpper())
        $denominator = [Math]::Pow(1024, $power)
    }

    process {
        $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path).TrimEnd('\')

        $itemData = [SC.IO.FileSearcher]::MeasureItem($Path, $true, $null)

        if ($ValueOnly) {
            [Math]::Round(($itemData[0] / $denominator), $Digits)
        }
        else {
            [PSCustomObject]@{
                Path           = $Path
                Size           = [Math]::Round(($itemData[0] / $denominator), $Digits)
                FileCount      = $itemData[1]
                DirectoryCount = $itemData[2]
            }
        }
    }
}

Function Set-CMDrive {
    <#
    .SYNOPSIS
    Import ConfigMgr module, create ConfigrMgr PS drive and set location to it.
    .DESCRIPTION
    Set current working directory to site code for access to ConfigMgr cmdlets. Some validation is in place to verify the site code marrys up to be of $Server.
    Called by main body.
    #>

    Param(
        [string]$SiteCode,
        [string]$Server,
        [string]$Path
    )

    # Import the ConfigurationManager.psd1 module
    if (-not(Get-Module ConfigurationManager)) {
        try {
            Import-Module ("{0}\..\ConfigurationManager.psd1" -f $ENV:SMS_ADMIN_UI_PATH)
        }
        catch {
            $Message = "Failed to import Configuration Manager module"
            Write-CMLogEntry -Value $Message -Severity 3 -Component "Initialisation"
            throw $Message
        }
    }

    try {
        # Connect to the site's drive if it is not already present
        if (-not (Get-PSDrive -Name $SiteCode -PSProvider CMSite -ErrorAction SilentlyContinue)) {
            New-PSDrive -Name $SiteCode -PSProvider CMSite -Root $Server -ErrorAction Stop | Out-Null
        }
        # Set the current location to be the site code.
        Set-Location ("{0}:\" -f $SiteCode) -ErrorAction Stop

        # Verify given sitecode
        if ((Get-CMSite -SiteCode $SiteCode | Select-Object -ExpandProperty SiteCode) -ne $SiteCode) { throw }

    } 
    catch {
        if (-not(Get-PSDrive -Name $SiteCode -PSProvider CMSite -ErrorAction SilentlyContinue)) {
            Set-Location $Path
            Remove-PSDrive -Name $SiteCode -Force
        }
        $Message = "Failed to create New-PSDrive with site code `"{0}`" and server `"{1}`"" -f $SiteCode, $Server
        Write-CMLogEntry -Value $Message -Severity 3 -Component "Initialisation"
        throw $Message
    }

}

Function New-ExcelReport {
    <#
    .SYNOPSIS
        Create the Excel report
    .DESCRIPTION
        Using the ImportExcel module create the Excel workbook with the result and several other worksheets creating useful views of the result
        Called by main body.
    #>

    [CmdletBinding()]
    Param(
        [String]$File,
        [Hashtable]$Data
    )
    try {
        $ExcelPackage = Export-Excel -Path $File -PassThru
        ForEach ($item in $Data.GetEnumerator()) {
            $Worksheet = Add-Worksheet -ExcelPackage $ExcelPackage -WorksheetName $item.Key
            switch -Regex ($item.Key) {
                "Summary" {
                    $ExcelPackage = Export-Excel -ExcelPackage $ExcelPackage -WorksheetName $Worksheet.Name -InputObject $item.Value -Activate -PassThru
                    [void]$ExcelPackage.Workbook.Worksheets[$Worksheet].InsertRow(1,1)
                    $LastRow = $ExcelPackage.Workbook.Worksheets[$Worksheet].Dimension.Rows
                    Set-ExcelRange -Range $ExcelPackage.Workbook.Worksheets[$Worksheet].Cells["A1"] -Value "Total:" -HorizontalAlignment "Right" -Bold
                    ForEach ($Letter in @("B","C","D")) {
                        $Formula = "=SUM({0}3:{1}{2})" -f $Letter, $Letter, ($LastRow + 1)
                        $Cell = "{0}1" -f $Letter
                        Set-ExcelRange -Range $ExcelPackage.Workbook.Worksheets[$Worksheet].Cells[$Cell] -Formula $Formula
                    }
                }
                default {
                    $ExcelPackage = Export-Excel -ExcelPackage $ExcelPackage -WorksheetName $Worksheet.Name -InputObject $item.Value -PassThru
                }
            }
        }
        Close-ExcelPackage -ExcelPackage $ExcelPackage
        Remove-Worksheet -Path $File -WorksheetName "Sheet1"
    }
    catch {
        $Message = "Failed to create Excel report ({0})" -f $NewExcelReportErr.Exception.Message
        Write-ScreenInfo -Message $Message -Type "Error" -Indent 1
        Write-CMLogEntry -Value $Message -Severity 3 -Component "Exit"
    }
    
}
#endregion

$Message = "Starting"
Write-ScreenInfo -Message $Message
Write-CMLogEntry -Value $Message -Severity 1 -Component "Initilisation"

# Write all parameters passed to script to log
ForEach($item in $PSBoundParameters.GetEnumerator()) {
    Write-CMLogEntry -Value ("- {0}: {1}" -f $item.Key, [String]::Join(",", $item.Value)) -Severity 1 -Component "Initilisation"
}

# Try and detemrine site code from $SiteServer
try {
    if ([string]::IsNullOrEmpty($SiteCode) -eq $true) {
        # Using a tmp variable because can't modify $SiteCode to fall outside of the ValidatePattern() attribute defined in the script's parameter block
        $tmp = Get-CimInstance -ComputerName $SiteServer -ClassName SMS_ProviderLocation -Namespace "ROOT\SMS" | Select-Object -ExpandProperty SiteCode
        if ($tmp.count -gt 1) {
            $Message = "Found multiple site codes: {0}" -f ($tmp -join ", ")
            Write-ScreenInfo -Message $Message -Type "Error"
            Write-CMLogEntry -Value $Message -Severity 3 -Component "Initilisation"
            return
        }
        else {
            # Reasonable assurance now the value confines to what's defined in ValidatePattern() attribute, so go ahead and reassign
            $SiteCode = $tmp
        }
        $Message = "Using site code: {0}" -f $SiteCode
        Write-ScreenInfo -Message $Message
        Write-CMLogEntry -Value $Message -Severity 1 -Component "Initilisation"
    }
}
catch {
    $Message = "Could not determine site code, please provide it using the -SiteCode parameter, quiting"
    Write-ScreenInfo -Message $Message -Type "Error"
    Write-CMLogEntry -Value $Message -Severity 3 -Component "Initilisation"
    return
}

# If user has given local path for $SourcesLocation, need to ensure we don't produce false positives where a similar folder structure exists on the remote machine and site server. e.g. packages let you specify local path on site server
if ((([System.Uri]$SourcesLocation).IsUnc -eq $false) -And ($env:COMPUTERNAME -ne $SiteServer)) {
    $Message = "Won't be able to determine unused folders with given local path while running remotely from site server, quitting"
    Write-ScreenInfo -Message $Message -Type "Error"
    Write-CMLogEntry -Value $Message -Severity 3 -Component "Initilisation"
    return
}

# Import ImportExcel module report if -ExcelReport is present
if ($ExcelReport.IsPresent -eq $true) {
    try {
        Import-Module ImportExcel -ErrorAction Stop
    }
    catch {
        $Message = "Unable to import ImportExcel module: {0}" -f $error[0].Exception.Message
        Write-ScreenInfo -Message $Message -Type "Error"
        Write-CMLogEntry -Value $Message -Severity 3 -Component "Initialisation"
        return
    }
    [version]$moduleVersion = (Get-Module ImportExcel | Sort-Object Version -Descending | Select-Object -ExpandProperty Version)[0]
    if ($moduleVersion -lt [version]"7.0.0") {
        $Message = "ImportExcel version is too old ({0}). Requires 7.0.0+." -f $moduleVersion.ToString()
        Write-ScreenInfo -Message $message -Type "Error"
        Write-CMLogEntry -Value $Message -Severity 3 -Component "Initialisation"
        return
    }
}

# Build the $Commands array ready for Get-CMContent
switch ($true) {
    ($Packages.IsPresent -eq $true) {
        [array]$Commands += "Get-CMPackage"
    }
    ($Applications.IsPresent -eq $true) {
        [array]$Commands += "Get-CMApplication"
    }
    ($Drivers.IsPresent -eq $true) {
        [array]$Commands += "Get-CMDriver"
    }
    ($DriverPackages.IsPresent -eq $true) {
        [array]$Commands += "Get-CMDriverPackage"
    }
    ($OSImages.IsPresent -eq $true) {
        [array]$Commands += "Get-CMOperatingSystemImage"
    }
    ($OSUpgradeImages.IsPresent -eq $true) {
        [array]$Commands += "Get-CMOperatingSystemInstaller"
    }
    ($BootImages.IsPresent -eq $true) {
        [array]$Commands += "Get-CMBootImage"
    }
    ($DeploymentPackages.IsPresent -eq $true) {
        [array]$Commands += "Get-CMSoftwareUpdateDeploymentPackage"
    }
    default {
        [array]$Commands = "Get-CMPackage", "Get-CMApplication", "Get-CMDriver", "Get-CMDriverPackage", "Get-CMOperatingSystemImage", "Get-CMOperatingSystemInstaller", "Get-CMBootImage", "Get-CMSoftwareUpdateDeploymentPackage"
    }
}

# Get NetBIOS of given $SiteServer parameter so it's similar format as $env:COMPUTERNAME used in body during folder/content object for loop
# And also for value pair in each content objects .AllPaths property (hashtable)
if ($SiteServer -as [IPAddress]) {
    $FQDN = [System.Net.Dns]::GetHostEntry($SiteServer) | Select-Object -ExpandProperty HostName
}
else {
    $FQDN = [System.Net.Dns]::GetHostByName($SiteServer) | Select-Object -ExpandProperty HostName
}
$SiteServer = $FQDN.Split(".")[0]

# Gather folders

$Message = "Gathering folders: {0}" -f $SourcesLocation
Write-ScreenInfo -Message $Message
Write-CMLogEntry -Value $Message -Severity 1 -Component "GatherFolders"
if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 1 -Activity "Running Get-CMUnusedSources" -PercentComplete 0 -Status $Message }
$GetAllFoldersSplat = @{
    Path                = $SourcesLocation
    UseAltFolderSearch  = $AltFolderSearch.IsPresent
}
if ($PSBoundParameters.ContainsKey("ExcludeFolders")) {
    $GetAllFoldersSplat.Add("ExcludeFolders", $ExcludeFolders)
}
$AllFolders = Get-AllFolders @GetAllFoldersSplat
$Message = "Done, number of gathered folders: {0}" -f $AllFolders.count
Write-ScreenInfo -Message $Message -Indent 1
Write-CMLogEntry -Value $Message -Severity 1 -Component "GatherFolders"

# Gather content objects

$OriginalPath = Get-Location | Select-Object -ExpandProperty Path
Set-CMDrive -SiteCode $SiteCode -Server $SiteServer -Path $OriginalPath

$Message = "Gathering content objects: {0}" -f ($Commands -replace "Get-CM" -join ", ")
Write-ScreenInfo -Message $Message
Write-CMLogEntry -Value $Message -Severity 1 -Component "GatherContentObjects"
if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 1 -Activity "Running Get-CMUnusedSources" -PercentComplete 33 -Status $Message }
$AllContentObjects = Get-CMContent -Commands $Commands -SiteServer $SiteServer -SiteCode $SiteCode
$Message = "Done, number of gathered content objects: {0}" -f $AllContentObjects.count
Write-ScreenInfo -Message $Message -Indent 1
Write-CMLogEntry -Value $Message -Severity 1 -Component "GatherContentObjects"

Set-Location $OriginalPath

$AllFolders | ForEach-Object -Begin {

    $Message = "Determining unused folders, using {0} threads" -f $Threads
    Write-ScreenInfo -Message $Message
    Write-CMLogEntry -Value $Message -Severity 1 -Component "Processing"
    if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 1 -Activity "Running Get-CMUnusedSources" -PercentComplete 66 -Status $Message }
    
    # Make Test-FileSystemAccess function available to all runspaces
    $Definition = Get-Content Function:\Test-FileSystemAccess -ErrorAction Stop
    $SessionStateFunction = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList 'Test-FileSystemAccess', $Definition
    $initialSessionState = [InitialSessionState]::CreateDefault()
    $InitialSessionState.Commands.Add($SessionStateFunction)

    # Create runspace pool, initialise the results array and script block it'll churn through
    $RSPool = [RunspaceFactory]::CreateRunspacePool(1, $Threads, $InitialSessionState, $Host)
    $RSPool.ApartmentState = "MTA"
    $RSPool.Open()
    [System.Collections.Generic.List[Object]]$RSResults = @()
    $RSScriptBlock = {
        Param (
            [string]$RSFolder,
            [System.Collections.Generic.List[Object]]$RSAllContentObjects
        )

        # Initialise the essentials
        [System.Collections.Generic.List[String]]$UsedBy = @()
        $IntermediatePath = $false
        $NotUsed = $false

        switch ([int](Test-FileSystemAccess -Path $RSFolder -Rights Read)) {
            5 {
                $UsedBy.Add("Access denied")
            }
            740 {
                $UsedBy.Add("Access denied (elevation required)")
            }
        }

        # Still continue anyway, despite access denied, because we can still determine if it's an exact match or intermediate path of a content object
        
        # Skip all the checking and just return $NotUsed = $true if there aren't any content objects
        if ($RSAllContentObjects.count -gt 0) {
            ForEach ($ContentObject in $RSAllContentObjects) {
                switch($true) {
                    ($_.SourcePathFLag -eq 3) {
                        # Filtered to exclude SourcePathFlag 3 so we can exclude false positives
                        # e.g. if content objects uses \\server\share\folder1\folder2 and $SourcesLocation is \\server\share\folder1
                        # but SourcePathFlag is 3, this could report \\server\share\folder1 as intermediate where it may not be
                        # Plus, no point iterating over them if we already know that the SourcePath isn't resolvable
                        break
                    }
                    ([string]::IsNullOrEmpty($ContentObject.SourcePath) -eq $true) {
                        # Content object source path is empty, no point continuing
                        break
                    }
                    ((([System.Uri]$SourcesLocation).IsUnc -eq $false) -And ($ContentObject.AllPaths.$RSFolder -eq $env:COMPUTERNAME)) {
                        # Content object source path is on local file system to the site server
                        $UsedBy.Add($ContentObject.Name)
                        break
                    }
                    (($ContentObject.AllPaths.Keys -contains $RSFolder) -eq $true) {
                        # By default the ContainsKey method ignores case
                        # A match has been found within the AllPaths property of the content object
                        $UsedBy.Add($ContentObject.Name)
                        break
                    }
                    (($ContentObject.AllPaths.Keys -match [Regex]::Escape($RSFolder)).Count -gt 0) {
                        # If any of the content object paths start with $RSFolder
                        $IntermediatePath = $true
                        break
                    }
                    ($ContentObject.AllPaths.Keys.Where{$RSFolder.StartsWith($_, "CurrentCultureIgnoreCase")}.Count -gt 0) {
                        # If $RSFolder starts wtih any of the content object paths
                        $IntermediatePath = $true
                        break
                    }
                    default {
                        # Folder isn't known to any content objects
                        $NotUsed = $true
                    }
                }
            }
        }
        else {
            $NotUsed = $true
        }

        switch ($true) {
            ($UsedBy.count -gt 0) {
                $UsedBy = $UsedBy -join ", "
                break
            }
            ($IntermediatePath -eq $true) {
                $UsedBy = "An intermediate folder (sub or parent folder)"
                break
            }
            ($NotUsed -eq $true) {
                $UsedBy = "Not used"
                break
            }
        }

        [PSCustomObject]@{
            Folder  = $RSFolder
            UsedBy  = $UsedBy -join ", "
        }

    }

    if ($NoProgress.IsPresent -eq $false) {
        if ($AllFolders.count -gt 150) {
            [Int32]$FolderInterval = $AllFolders.count * 0.01
        }
        else {
            $FolderInterval = 2
        }
    }

    $Message = "Adding jobs to queue"
    Write-ScreenInfo -Message $Message -Indent 1
    Write-CMLogEntry -Value $Message -Severity 1 -Component "Processing"

} -Process {

    $Folder = $_

    if ($NoProgress.IsPresent -eq $false) {
        if (($AllFolders.IndexOf($Folder) % $FolderInterval) -eq 0) {
            [Int32]$Percentage = ($AllFolders.IndexOf($Folder) / $AllFolders.count * 100)
            Write-Progress -Id 2 -Activity "Adding jobs to queue" -PercentComplete $Percentage -Status ("{0}% complete" -f $Percentage) -ParentId 1
        }
    }

    $Runspace = [PowerShell]::Create()
    $null = $Runspace.AddScript($RSScriptBlock)
    $null = $Runspace.AddArgument($Folder)
    $null = $Runspace.AddArgument($AllContentObjects)
    $Runspace.Runspacepool = $RSPool
    $RSResults.Add( [PSCustomObject]@{ Pipe = $Runspace; Status = $Runspace.BeginInvoke() } )
    
} -End {
    
    $Message = "Done, waiting for jobs to complete"
    Write-ScreenInfo -Message $Message -Indent 2
    Write-CMLogEntry -Value $Message -Severity 1 -Component "Processing"

    [System.Collections.Generic.List[Object]]$Result = @()

    # Process runspaces, wait for their results and clean up when complete
    $TotalRunspaces = $RSResults.count
    while ($RSResults.Status -ne $null) {
        if ($NoProgress.IsPresent -eq $false) { 
            $TotalNotComplete = $RSResults.Where( { $_.Status -eq $null } ).count
            Write-Progress -Id 2 -Activity "Evaluating folders" -Status ("{0} folders remaining" -f ($TotalRunspaces-$TotalNotComplete)) -PercentComplete ($TotalNotComplete/$TotalRunspaces * 100) -ParentId 1
        }
        $Completed = $RSResults.Where( { $_.Status.IsCompleted -eq $true } )
        ForEach ($item in $Completed) {
            # Reference index 0 so we can grab the PSCustomobject inside the PSDataCollection object
            $Result.Add(($item.Pipe.EndInvoke($item.Status)[0]))
            $item.Pipe.Dispose()
            $item.Status = $null
        }
        Start-Sleep -Seconds 2
    }

    # Clean up runspace pool
    $RSPool.Dispose()

    $Message = "Done determining unused folders"
    Write-ScreenInfo -Message $Message -Indent 1
    Write-CMLogEntry -Value $Message -Severity 1 -Component "Processing"

    # Update Write-Progress
    if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity "Evaluating folders" -Completed -ParentId 1 }
    if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 1 -Activity "Running Get-CMUnusedSources" -PercentComplete 100 -Status "Finishing up" }

    $Message = "Calculating used disk space by unused folders"
    Write-ScreenInfo -Message $Message
    Write-CMLogEntry -Value $Message -Severity 1 -Component "Exit"
    if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity $Message -PercentComplete 0 -ParentId 1 }

    # Get all "Invalid paths" content objects
    $InvalidPaths = $AllContentObjects.Where( { $_.SourcePathFlag -eq 3 -And -not [string]::IsNullOrEmpty($_.SourcePath) } ) | Select-Object * -ExcludeProperty SourcePathFlag,AllPaths

    # Get all "Not used" folders and create a blank PSCustomObject if null for a clean worksheet in case -ExcelReport is specified
    $NotUsedFolders = $Result.Where( { $_.UsedBy -eq "Not used" } )
    # Calculate total MB used on size unused by ConfigMgr, used in summary printed at the end and also for Add-ExcelReportWorksheet
    if ($NotUsedFolders.count -eq 0) {
        $NotUsedFolders = [PSCustomObject]@{
            Folder  = $null
            UsedBy  = $null
        }
        $SummaryNotUsedFolders = [PSCustomObject]@{
            Path            = 0
            Size            = 0
            FileCount       = 0
            DirectoryCount  = 0
        }
        $SummaryNotUsedFoldersMB,$SummaryNotUsedFoldersFileCount,$SummaryNotUsedFoldersDirectoryCount = 0,0,0
    }
    else {
        $SummaryNotUsedFolders = $NotUsedFolders | Sort-Object Folder | ForEach-Object {
            $current = $_
            if (($previous) -And ($current.Folder.StartsWith($previous.Folder))) {
                # Do nothing
            }
            else {
                $previous = $current
                $current.Folder | Measure-ChildItem -Unit MB -Digits 2
            }
        } | Select-Object Path, @{Label="Size (MB)"; Expression={$_.Size}}, FileCount, DirectoryCount
        
        $SummaryNotUsedFoldersMB = [Math]::Round(($SummaryNotUsedFolders | Measure-Object "Size (MB)" -Sum | Select-Object -ExpandProperty Sum), 2)
        $SummaryNotUsedFoldersFileCount = $SummaryNotUsedFolders | Measure-Object FileCount -Sum | Select-Object -ExpandProperty Sum
        $SummaryNotUsedFoldersDirectoryCount = $SummaryNotUsedFolders | Measure-Object DirectoryCount -Sum | Select-Object -ExpandProperty Sum
    }

    $Message = "Done calculating used disk space by unused folders"
    Write-ScreenInfo -Message $Message -Indent 1
    Write-CMLogEntry -Value $Message -Severity 1 -Component "Exit"

    # Write $Result to log file
    # I know Write-CMLogEntry has Enabled parameter but having it here too just makes sense - to save the gazillion of loops for something that may be disabled anyway
    # May consider deleting this section, enough about the result is written to file
    if ($Log.IsPresent -eq $true) {
        $Message = "Writing result to log file"
        if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity $Message -PercentComplete 25 -ParentId 1 }
        Write-CMLogEntry -Value $Message -Severity 1 -Component "Processing"
        ForEach ($item in $Result) {
            switch -regex ($item.UsedBy) {
                "Access denied" {
                    $Severity = 2
                }
                default {
                    $Severity = 1
                }
            }
            Write-CMLogEntry -Value ($item.Folder + ": " + $item.UsedBy) -Severity $Severity -Component "Processing"
        }
    }

    # Export $Result to file
    if ($ExportReturnObject.IsPresent -eq $true) {
        try {
            $Message = "Exporting PowerShell return object"
            Write-ScreenInfo -Message $Message
            Write-CMLogEntry -Value $Message -Severity 1 -Component "Exit"
            if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity $Message -PercentComplete 50 -ParentId 1 }
            Export-Clixml -LiteralPath (($PSCommandPath | Split-Path -Parent) + "\" + ($PSCommandPath | Split-Path -Leaf) + "_" + $JobId + "_result.xml") -InputObject $Result
            $Message = "Done exporting PowerShell return object"
            Write-ScreenInfo -Message $Message -Indent 1
            Write-CMLogEntry -Value $Message -Severity 1 -Component "Exit"
        }
        catch {
            $Message = "Failed to export PowerShell object: {0}" -f $error[0].Exception.Message
            Write-ScreenInfo -Message $Message -Type "Error" -Indent 1
            Write-CMLogEntry -Value $Message -Severity 3 -Component "Exit"
        }
    }

    # Export $AllContentObjects to file
    if ($ExportCMContentObjects.IsPresent -eq $true) {
        try {
            $Message = "Exporting PowerShell ConfigMgr content objects object"
            Write-ScreenInfo -Message $Message
            Write-CMLogEntry -Value $Message -Severity 1 -Component "Exit"
            if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity $Message -PercentComplete 75 -ParentId 1 }
            Export-Clixml -LiteralPath (($PSCommandPath | Split-Path -Parent) + "\" + ($PSCommandPath | Split-Path -Leaf) + "_" + $JobId + "_cmobjects.xml") -InputObject $AllContentObjects
            $Message = "Done exporting PowerShell ConfigMgr content objects object"
            Write-ScreenInfo -Message $Message -Indent 1
            Write-CMLogEntry -Value $Message -Severity 1 -Component "Exit"
        }
        catch {
            $Message = "Failed to export PowerShell object: {0}" -f $error[0].Exception.Message
            Write-ScreenInfo -Message $Message -Indent 1
            Write-CMLogEntry -Value $Message -Severity 3 -Component "Exit"
        }
    }

    # Write $Result to Excel using ImportExcel
    if ($ExcelReport.IsPresent -eq $true) {
        $Message = "Creating Excel report"
        Write-ScreenInfo -Message $Message
        Write-CMLogEntry -Value $Message -Severity 1 -Component "Exit"
        if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity $Message -PercentComplete 100 -ParentId 1 }
        New-ExcelReport -File ("{0}_{1}.xlsx" -f $PSCommandPath, $JobId) -ErrorAction "Stop" -ErrorVariable NewExcelReportErr -Data @{
            "Result"                = $Result
            "Summary"               = $SummaryNotUsedFolders
            "Not used folders"      = $NotUsedFolders
            "Invalid paths"         = if ($InvalidPaths.count -gt 0 -And $null -ne $InvalidPaths) {
                $InvalidPaths
            } 
            else {
                [PSCustomObject]@{
                    ContentType     = $null
                    UniqueID        = $null
                    Name            = $null
                    IsRetired       = $null
                    SourcePath      = $null
                    SourcePathFlag  = $null
                    SizeMB          = $null
                }
            }
            "Content objects"   = if ($AllContentObjects.count -gt 0 -And $null -ne $AllContentObjects) {
                $AllContentObjects | Select-Object -Property * -ExcludeProperty AllPaths
            }
            else {
                [PSCustomObject]@{
                    ContentType     = $null
                    UniqueID        = $null
                    Name            = $null
                    IsRetired       = $null
                    SourcePath      = $null
                    SourcePathFlag  = $null
                    SizeMB          = $null
                }
            }
        }
        if ($NoProgress.IsPresent -eq $false) { Write-Progress -Id 2 -Activity $Message -Completed -ParentId 1 }
        $Message = "Done creating Excel report"
        Write-ScreenInfo -Message $Message -Indent 1
        Write-CMLogEntry -Value $Message -Severity 1 -Component "Exit"
    }

    # Stop clock for runtime
    $StopTime = (Get-Date) - $StartTime

    $EndSummary = @(
        ("---------------------------------------------------------------------------"),
        ("Folders in {0}: {1}" -f $SourcesLocation, $AllFolders.count),
        ("Folders where access denied: {0}" -f $Result.Where( { $_.UsedBy -like "Access denied*" } ).count),
        ("Folders unused: {0}" -f $NotUsedFolders.count),
        ("Potential disk space savings in `"{0}`": {1} MB" -f $SourcesLocation, $SummaryNotUsedFoldersMB),
        ("Content objects processed: {0}" -f ($Commands -replace "Get-CM" -join ", ")),
        ("Content objects: {0}" -f $AllContentObjects.count),
        ("Runtime: {0}" -f $StopTime.ToString()),
        ("---------------------------------------------------------------------------"),
        ("Finished")
    )

    Write-ScreenInfo -Message $EndSummary

    # Write summary to log
    ForEach ($item in $EndSummary) {
        Write-CMLogEntry -Value $item -Severity 1 -Component "Exit"
    }

    return $Result
}