StainedGlass.psm1



function Backup-SchTasks
{
<#
.SYNOPSIS
Exports the local list of Scheduled Tasks into a single XML file.
 
.DESCRIPTION
This backs up the Scheduled Tasks set up on the system to a file which can be restored
again later. This can be useful before migrating a server/workstation, or before making
significant changes to some tasks that may need to be reverted.
 
.NOTES
Notice that the root Tasks element does not have an XML namespace, but the individual
Task child element elements are in the http://schemas.microsoft.com/windows/2004/02/mit/task
namespace.
 
.FUNCTIONALITY
Scheduled Tasks
 
.LINK
https://msdn.microsoft.com/library/windows/desktop/bb736357.aspx
 
.EXAMPLE
Backup-SchTasks
 
Backs up Windows Scheduled Tasks to tasks.xml.
 
.EXAMPLE
Backup-SchTasks tasks-backup.xml -Stylesheet tasks.css
 
Saves scheduled tasks to tasks-backup.xml using tasks.css as a display stylesheet.
#>


[CmdletBinding()][OutputType([void])] Param(
# The XML file to backup the list of scheduled tasks to.
[Parameter(Position=0)][string]$Path = 'tasks.xml',
# A CSS or XSLT stylesheet to use when viewing the scheduled tasks XML.
[string] $Stylesheet
)

'<?xml version="1.0"?>' |Out-File $Path -Encoding utf8
if($Stylesheet)
{
    "<?xml-stylesheet href=`"$Stylesheet`" type=`"text/$((Split-Path $Stylesheet -Extension).Trim('.'))`"?>" |
        Out-File $Path -Encoding utf8 -Append
}
schtasks /query /xml |
    Where-Object {$_ -notlike '<?xml *?>'} |
    Out-File $Path -Encoding utf8 -Width ([int]::MaxValue) -Append

}

function Backup-Workstation
{
<#
.SYNOPSIS
Adds various configuration files and exported settings to a ZIP file.
 
.LINK
Export-InstalledPackages.ps1
 
.LINK
Export-EdgeKeywords.ps1
 
.LINK
Export-SecretVault.ps1
 
.EXAMPLE
Backup-Workstation.ps1
 
Saves various config data to COMPUTERNAME-20230304T125000.zip.
#>


#TODO: Add or replace dependencies.
[CmdletBinding()] Param(
[Parameter(Position=0)][string] $Path =
    (Join-Path ~ ('{0}-{1:yyyyMMdd\THHmmss}.zip' -f $env:COMPUTERNAME,(Get-Date)))
)

filter Copy-ItemToBackup
{
    Param(
    [Parameter(Position=0)][string] $Destination,
    [Parameter(ValueFromPipeline=$true)][string] $Item
    )
    if(!(Test-Path $Item)) {Write-Verbose "'$Item' not found"; return}
    $path = "$(Resolve-Path $Item)"
    $relpath = $path.Replace((Join-Path $env:USERPROFILE ''),'')
    $subdir = Split-Path $relpath
    if(Test-Path $path -Type Leaf)
    {
        if($subdir) {mkdir (Join-Path $Destination $subdir) |Out-Null}
        Copy-Item -Path $path -Destination (Join-Path $Destination $relpath)
    }
    elseif(Test-Path $path -Type Container)
    {
        if($subdir) {mkdir (Join-Path $Destination $subdir) |Out-Null}
        Copy-Item -Path $path -Destination (Join-Path $Destination $relpath) -Container -Recurse
    }
}

function Copy-ContentToBackup
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseProcessBlockForPipelineCommand','',
    Justification='This script uses $input within an End block.')]
    Param(
    [Parameter(Position=0)][string] $Destination,
    [Parameter(Position=1)][string] $FileName,
    [Parameter(ValueFromPipeline=$true)][psobject] $Item
    )
    End {$input |ConvertTo-Json |Out-File (Join-Path $Destination $FileName) utf8}
}

function Backup-Workstation
{
    $dir = Join-Path ([io.path]::GetTempPath()) "$(New-Guid)"
    New-Item $dir -ItemType Directory |Out-Null
    @(
        Join-Path ~ .ssh
        Join-Path ~ SeqCli.json
        Join-Path ~ .npmrc
        Join-Path $env:APPDATA 'GitHub CLI' config.yml
        Join-Path $env:APPDATA NuGet nuget.config
        Join-Path $env:LOCALAPPDATA Packages Microsoft.WindowsTerminal_* LocalState settings.json
    ) |Copy-ItemToBackup $dir
    @{
        User    = [Environment]::GetEnvironmentVariables('User')
        Machine = [Environment]::GetEnvironmentVariables('Machine')
    } |Copy-ContentToBackup $dir env-vars.json
    if(Join-Path $env:LocalAppData Microsoft Edge 'User Data' Default |Test-Path -Type Container)
    {
        Export-EdgeKeywords.ps1 |Copy-ContentToBackup $dir edge-keywords.json
    }
    Export-SecretVault.ps1 -Confirm:$false |Copy-ContentToBackup $dir secret-vault.json
    Export-InstalledPackages.ps1 |Copy-ContentToBackup $dir packages.json
    Get-ChildItem $dir |Compress-Archive -DestinationPath $Path
    Remove-Item $dir -Recurse -Force
}

Backup-Workstation

}

function Convert-ChocolateyToWinget
{
<#
.SYNOPSIS
Change from managing various packages with Chocolatey to WinGet.
 
.EXAMPLE
Convert-ChocolateyToWinget -SkipPackages autohotkey,git
 
Moves package management from Chocolatey to WinGet for everything except
autohotkey (maybey you are managing Adobe Digital Editions with Chocolatey),
or git (maybe you are managing PoshGit with Chocolatey).
 
.FUNCTIONALITY
System and updates
#>


[CmdletBinding(ConfirmImpact='High',SupportsShouldProcess=$true)] Param(
# A specific package to convert
[Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,ValueFromRemainingArguments=$true)]
[string[]] $PackageName,
# Chocolatey packages to skip.
[string[]] $SkipPackages,
# Fully uninstalls the Chocolatey package before installing the corresponding winget package,
# instead of simply removing the package from the Chocolatey package list.
[switch] $Force
)
Begin
{
$cunistparams = if($Force) {@()} else {@('-n','--skipautouninstaller')}
$packages = Data {ConvertFrom-StringData @'
7zip=7zip.7zip
ag=JFLarvoire.Ag
audacity=Audacity.Audacity
authy-desktop=Twilio.Authy
autohotkey=Lexikos.AutoHotkey
azure-cli=Microsoft.AzureCLI
azure-data-studio=Microsoft.AzureDataStudio
azure-functions-core-tools=Microsoft.AzureFunctionsCoreTools
cdburnerxp=Canneverbe.CDBurnerXP
dellcommandupdate-uwp=Dell.CommandUpdate
dotnet-sdk=Microsoft.dotnet
dotnetfx=Microsoft.dotNetFramework
dropbox=Dropbox.Dropbox
emeditor=Emurasoft.EmEditor
etcher=Balena.Etcher
firefox=Mozilla.Firefox
# winget is unable to pin packages, so specific hardware is not supported
#geforce-experience=Nvidia.GeForceExperience
gh=GitHub.cli
git=Git.Git
github=GitHub.GitHubDesktop
github-desktop=GitHub.GitHubDesktop
gitkraken=Axosoft.GitKraken
google-chrome-x64=Google.Chrome
googlechrome=Google.Chrome
googledrive=Google.Drive
googleearth=Google.EarthPro
gotomeeting=LogMeIn.GoToMeeting
gource=acaudwell.Gource
graphviz=Graphviz.Graphviz
handbrake=HandBrake.HandBrake
hdhomerun-view=Silicondust.HDHomeRunTECH
inkscape=Inkscape.Inkscape
irfanview=IrfanSkiljan.IrfanView
libreoffice=TheDocumentFoundation.LibreOffice
microsoft-edge=Microsoft.Edge
microsoft-teams=Microsoft.Teams
# winget is unable to pin packages, so versions that use incompatible OpenSSL versions would be installed
#microsoft-windows-terminal=Microsoft.WindowsTerminal
mp3tag=Mp3tag.Mp3tag
mremoteng=mRemoteNG.mRemoteNG
# winget is unable to pin packages, so versions that use incompatible OpenSSL versions would be installed
#nodejs=OpenJS.NodeJS
NSwagStudio=RicoSuter.NSwagStudio
oh-my-posh=JanDeDobbeleer.OhMyPosh
onedrive=Microsoft.OneDrive
# winget isn't as scriptable as choco for updating the shell basics
#powershell-core=Microsoft.PowerShell
powertoys=Microsoft.PowerToys
python=Python.Python.3
python3=Python.Python.3
rpi-imager=RaspberryPiFoundation.RaspberryPiImager
slack=SlackTechnologies.Slack
steam=Valve.Steam
steam-client=Valve.Steam
sumatrapdf=SumatraPDF.SumatraPDF
sumatrapdf.install=SumatraPDF.SumatraPDF
ubisoft-connect=Ubisoft.Connect
# vcredist-all is a convenient metapackage, which winget doesn't really support
visualstudio2019buildtools=Microsoft.VisualStudio.2019.BuildTools
visualstudio2022buildtools=Microsoft.VisualStudio.2022.BuildTools
vlc=VideoLAN.VLC
vlc.install=VideoLAN.VLC
vscode=Microsoft.VisualStudioCode
vscode.install=Microsoft.VisualStudioCode
vscode-insiders=Microsoft.VisualStudioCode.Insiders
vscode-insiders.install=Microsoft.VisualStudioCode.Insiders
windirstat=WinDirStat.WinDirStat
winmerge=WinMerge.WinMerge
zoom=Zoom.Zoom
'@
}
}
Process
{
    if(!$PackageName) {$PackageName = $packages.Keys |Sort-Object}
    foreach($p in $PackageName)
    {
        if($p -in $SkipPackages) {Write-Verbose "Skipping Chocolatey package '$p'"; continue}
        if(!$packages.ContainsKey($p)) {Write-Error "Chocolatey package '$p' has not been mapped to a WinGet package"; continue}
        if(!(choco list $p -erl --idonly |Select-Object -skip 1))
        {Write-Verbose "Chocolatey package '$p' not found, skipping."; continue}
        if(!$PSCmdlet.ShouldProcess("Chocolatey package '$p' with WinGet package '$($packages.$p)'",'replace')) {continue}
        Write-Verbose "Uninstalling Chocolatey package '$p'"
        choco uninstall $p -y @cunistparams
        Write-Verbose "Installing WinGet package '$($packages.$p)'"
        winget install -e --id $packages.$p
    }
}

}

function ConvertFrom-CimInstance
{
<#
.SYNOPSIS
Convert a CimInstance object to a PSObject.
 
.INPUTS
Microsoft.Management.Infrastructure.CimInstance to convert to a PSObject.
 
.OUTPUTS
PSObject converted from the CimInstance entered.
 
.FUNCTIONALITY
Scheduled Tasks
 
.EXAMPLE
$tasks = Get-ScheduledTask |ConvertFrom-CimInstance
 
Gets the scheduled tasks as PSObjects that support tab completion and can be serialized and exported.
#>


[CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param(
# The CimInstance object to convert to a PSObject.
[Parameter(Position=0,ValueFromPipeline=$true)]
[Microsoft.Management.Infrastructure.CimInstance] $InputObject
)
Process
{
    $value = [ordered]@{'#CimClassName' = $InputObject.CimClass.CimClassName}
    foreach($name in $InputObject.CimClass.CimClassProperties)
    {
        if($null -eq $InputObject.$name) {continue}
        switch($InputObject.CimInstanceProperties[$name].CimType)
        {
            Instance      {$value[$name] = $InputObject.$name |ConvertFrom-CimInstance}
            InstanceArray {$value[$name] = [psobject[]]@($InputObject.$name |ConvertFrom-CimInstance)}
            default       {$value[$name] = $InputObject.$name}
        }
    }
    [pscustomobject]$value
}

}

function ConvertTo-LogParserTimestamp
{
<#
.SYNOPSIS
Formats a datetime as a LogParser literal.
 
.INPUTS
System.DateTime to encode for use as a literal in a LogParser query.
 
.OUTPUTS
System.String to use as a timestamp literal in a LogParser query.
 
.FUNCTIONALITY
Date and time
 
.LINK
https://www.microsoft.com/en-us/download/details.aspx?id=24659
 
.EXAMPLE
logparser "select * from ex17*.log where to_localtime(timestamp(date,time)) < $(Get-Date|ConvertTo-LogParserTimestamp)"
#>


[CmdletBinding()][OutputType([string])] Param(
# The DateTime value to convert to a LogParser literal.
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][datetime]$Value
)
Process{"timestamp('$(Get-Date $Value -Format 'yyyy-MM-dd HH:mm:ss')','yyyy-MM-dd HH:mm:ss')"}

}

function Copy-SchTasks
{
<#
.SYNOPSIS
Copy scheduled jobs from another computer to this one, using a GUI list to choose jobs.
 
.FUNCTIONALITY
Scheduled Tasks
 
.EXAMPLE
Copy-SchTasks SourceComputer DestComputer
 
Attempts to copy tasks from SourceComputer to DestComputer.
#>


[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter','',
Justification='The parameter is used elsewhere.')]
[CmdletBinding()][OutputType([void])] Param(
# The name of the computer to copy jobs from.
[Parameter(Mandatory=$true,Position=0)][Alias('CN','Source')][string]$ComputerName,
# The name of the computer to copy jobs to (local computer by default).
[Parameter(Position=1)][Alias('To','Destination')][string]$DestinationComputerName = $env:COMPUTERNAME
)
$TempXml= [io.path]::GetTempFileName()
$CredentialCache = @{}
function Get-CachedCredentialFor([Parameter(Mandatory=$true,Position=0)][string]$UserName)
{
    if(!$CredentialCache.ContainsKey($UserName))
    { $CredentialCache.Add($UserName,(Get-Credential -Message "Enter credentials for $UserName tasks" -UserName $UserName)) }
    $CredentialCache[$UserName]
}
filter ConvertFrom-Credential
([Parameter(Mandatory=$true,Position=0,ValueFromPipeline=$true)][PSCredential][Management.Automation.Credential()]$Credential)
{ return $Credential.Password |ConvertFrom-SecureString -AsPlainText }
schtasks /query /s $ComputerName /v /fo csv |
    ConvertFrom-Csv |
    Where-Object {$_.HostName -ne 'HostName'} |
    Out-GridView -PassThru -Title 'Select jobs to copy' |
    Select-Object TaskName,'Run As User' -Unique |
    ForEach-Object {
        schtasks /query /s $ComputerName /tn $_.TaskName /xml ONE |
            Out-File $TempXml unicode -Width ([int]::MaxValue)
        schtasks /create /s $DestinationComputerName /tn $_.TaskName /ru ($_.'Run As User') `
            /rp (Get-CachedCredentialFor $_.'Run As User' |ConvertFrom-Credential) /xml $TempXml
        Remove-Item $TempXml
    }
$CredentialCache.Clear()

}

function Find-ProjectPackages
{
<#
.SYNOPSIS
Find modules used in projects.
 
.INPUTS
System.String containing a package name (wildcards supported).
 
.OUTPUTS
System.Management.Automation.PSCustomObject each with properties for the Name,
Version, and File of packages found.
 
.FUNCTIONALITY
Packages and libraries
 
.LINK
Select-Xml
 
.LINK
ConvertFrom-Json
 
.EXAMPLE
Find-ProjectPackages jQuery*
 
Name Version File
---- ------- ----
jquery.datatables 1.10.9 C:\Repo\packages.config
jQuery 1.7 C:\Repo\packages.config
jQuery 1.8.3 C:\OtherRepo\packages.config
#>


[CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param(
<#
The name of a package to search for.
Wildcards (as supported by -like) are allowed.
#>

[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][string] $PackageName,
<#
The path of a folder to search within.
Uses the current working directory ($PWD) by default.
#>

[string] $Path = $PWD,
# The minimum (inclusive) version of the package to return.
[version] $MinVersion,
# The maximum (inclusive) version of the package to return.
[version] $MaxVersion
)
Begin
{
    function Compare-Version([string]$version)
    {
        if(!$MinVersion -and !$MaxVersion) {return $true}
        if(!($version -match '(?<Version>\b\d+\.\d+(?:\.\d+)?)')) {Write-Warning "Can't parse version $version"; return $true}
        $v = [version]$Matches.Version
        if(!$MaxVersion) {return $v -ge $MinVersion}
        elseif(!$MinVersion) {return $v -le $MaxVersion}
        else {return ($v -ge $MinVersion) -and ($v -le $MaxVersion)}
    }
    $action = 'Find Project Packages'
    Write-Progress $action 'Getting proj package lists' -PercentComplete 0
    [string[]]$projFiles = Get-ChildItem $Path -Recurse -Filter *proj |Select-Object -ExpandProperty FullName
    if(!$projFiles) {[string[]]$projFiles = @()}
    Write-Verbose "Found $($projFiles.Length) *proj files."
    Write-Progress $action 'Getting NuGet package lists' -PercentComplete 15
    [string[]]$nugetFiles = Get-ChildItem $Path -Recurse -Filter packages.config |Select-Object -ExpandProperty FullName
    if(!$nugetFiles) {[string[]]$nugetFiles = @()}
    Write-Verbose "Found $($nugetFiles.Length) packages.config files."
    Write-Progress $action 'Getting npm package lists' -PercentComplete 30
    [string[]]$npmFiles   = Get-ChildItem $Path -Recurse -Filter package.json |Select-Object -ExpandProperty FullName
    if(!$npmFiles) {[string[]]$npmFiles = @()}
    Write-Verbose "Found $($npmFiles.Length) package.json files."
    Write-Progress $action 'Getting paket.lock package lists' -PercentComplete 45
    [string[]]$paketFiles   = Get-ChildItem $Path -Recurse -Filter paket.lock |Select-Object -ExpandProperty FullName
    if(!$paketFiles) {[string[]]$paketFiles = @()}
    Write-Verbose "Found $($paketFiles.Length) paket.lock files."
    $max = $projFiles.Length + $nugetFiles.Length + $npmFiles.Length + $paketFiles.Length
    Write-Verbose "Found $max package files."
    if(!$max) {Write-Error "No package lists found!"; return}
    $packages,$i = (New-Object Collections.ArrayList),0
    foreach($file in $projFiles)
    {
        Write-Verbose "Parsing $file"
        Write-Progress $action "Parsing *proj package files: found $($projFiles.Count)" -CurrentOperation $file -PercentComplete (10*$i++/$max+60)
        $p = (dotnet list $file package --format json |ConvertFrom-Json).projects |
            Select-Object -ExpandProperty frameworks |
            Where-Object {$_.PSObject.Properties.Match('topLevelPackages').Count} |
            Select-Object -ExpandProperty topLevelPackages |
            ForEach-Object {[pscustomobject]@{
                name    = $_.id
                version = $_.resolvedVersion
                file    = $file
            }}
        if(!$p) {Write-Verbose "No packages found in $file"; continue}
        [void]$packages.AddRange([object[]]$p)
    }
    foreach($file in $nugetFiles)
    {
        Write-Verbose "Parsing $file"
        Write-Progress $action "Parsing NuGet package files: found $($nugetFiles.Count)" -CurrentOperation $file -PercentComplete (10*$i++/$max+70)
        $p = Select-Xml /packages/package $file |Select-Object -ExpandProperty Node
        if(!$p) {Write-Verbose "No packages found in $file"; continue}
        [void]$packages.AddRange([object[]]( $p |ForEach-Object {[pscustomobject]@{
            name    = $_.id
            version = $_.version
            file    = $file
        }}))
    }
    foreach($file in $npmFiles)
    {
        Write-Verbose "Parsing $file"
        Write-Progress $action "Parsing npm package files: found $($npmFiles.Count)" -CurrentOperation $file -PercentComplete (10*$i++/$max+80)
        $j = ConvertFrom-Json (Get-Content $file -Raw)
        if(!(Get-Member -InputObject $j -Name dependencies)) {Write-Verbose "No dependencies found in $file"; continue}
        $p = Get-Member -InputObject $j.dependencies -MemberType NoteProperty |Select-Object -ExpandProperty Name
        if(!$p) {Write-Verbose "No packages found in $file"; continue}
        [void]$packages.AddRange([object[]]( $p |ForEach-Object {[pscustomobject]@{
            name    = $_
            version = $j.dependencies.$_
            file    = $file
        }}))
    }
    foreach($file in $paketFiles)
    {
        Write-Verbose "Parsing $file"
        Write-Progress $action "Parsing paket.lock package files: found $($paketFiles.Count)" -CurrentOperation $file -PercentComplete (10*$i++/$max+90)
        $lockpattern = '\A\s{4}(?<Name>\w\S+)\s\((?:>= )?(?<Version>\d+(?:\.\d+)+)\b'
        $p = Get-Content $file |ForEach-Object {if($_ -match $lockpattern){[pscustomobject]@{Name=$Matches.Name;Version=$Matches.Version}}}
        if(!$p) {Write-Verbose "No packages found in $file"; continue}
        [void]$packages.AddRange([object[]]( $p |ForEach-Object {[pscustomobject]@{
            name    = $_.Name
            version = $_.Version
            file    = $file
        }}))
    }
    $max = $packages.Count
}
Process
{
    $packages |
        Where-Object {$_.Name -like $PackageName} |
        Where-Object {Compare-Version $_.Version}
}

}

function Get-ADServiceAccountInfo
{
<#
.SYNOPSIS
Lists the Global Managed Service Accounts for the domain, including the computers they are bound to.
 
.EXAMPLE
Get-ADServiceAccountInfo |Format-Table -AutoSize
 
Name HostComputers LastLogonDate Description Account
---- ------------- ------------- ----------- -------
service1 SERVERA 2023-08-27 11:14:19 First MSA {}
service2 SERVERB 2023-08-27 10:27:03 Second MSA {}
serivce3 SERVERC 2023-08-25 17:19:49 Third MSA {}
#>


[CmdletBinding()] Param(
[Parameter(Position=0)][string] $Filter = '*'
)
Get-ADServiceAccount -Filter $Filter -Properties Description,LastLogonDate,HostComputers |
    ForEach-Object {[pscustomobject]@{
        Name          = $_.Name
        HostComputers = $_.HostComputers |
            ForEach-Object {Get-ADComputer $_} |
            Select-Object -ExpandProperty Name
        LastLogonDate = $_.LastLogonDate
        Description   = $_.Description
        Account       = $_
    }}

}

function Get-ADUserStatus
{
<#
.SYNOPSIS
Returns the current login properties of an ActiveDirectory user.
 
.LINK
https://learn.microsoft.com/powershell/module/activedirectory/get-aduser
 
.LINK
Get-ADUser
 
.EXAMPLE
Get-ADUserStatus alans
 
PasswordExpires : 07/17/2023 12:01:01 AM
BadLogonsRemaining : 5
AccountExpirationDate :
AccountExpires : 9223372036854775807
AccountLockoutTime :
BadLogonCount : 0
BadPwdCount : 0
DistinguishedName : CN=Alan Smithee,OU=Directors,DC=example,DC=local
Enabled : True
GivenName : Alan
LastBadPasswordAttempt : 04/17/2023 2:16:20 PM
LastLogonDate : 04/12/2023 5:07:55 PM
LockedOut : False
Name : Alan Smithee
ObjectClass : user
ObjectGUID : 0a2e6b9c-83d5-466b-b45b-69d6fe626b08
PasswordExpired : False
PasswordLastSet : 04/18/2023 12:01:01 AM
PwdLastSet : 133262748613029225
SamAccountName : alans
SID : S-1-5-21-3351665499-1662801859-681883470-29151
Surname : Smithee
UserPrincipalName : alans@example.local
#>


[CmdletBinding()][OutputType([Microsoft.ActiveDirectory.Management.ADUser])] Param(
[Parameter(Position=0,Mandatory=$true)][Microsoft.ActiveDirectory.Management.ADUser] $Identity
)
$policy = Get-ADDefaultDomainPasswordPolicy
return Get-ADUser -Identity $Identity -Properties AccountExpirationDate, AccountExpires, AccountLockoutTime, BadLogonCount,
    BadPwdCount, LastBadPasswordAttempt, LastLogonDate, LockedOut, PasswordExpired, PasswordLastSet, PwdLastSet |
    Add-NoteProperty.ps1 PasswordExpires {$_.PasswordLastSet + $policy.MaxPasswordAge} -Force -PassThru |
    Add-NoteProperty.ps1 BadLogonsRemaining {$policy.LockoutThreshold - $_.BadLogonCount} -Force -PassThru
    #TODO: Add or replace dependencies.

}

function Get-AspNetEvents
{
<#
.SYNOPSIS
Parses ASP.NET errors from the event log on the given server.
 
.OUTPUTS
System.Management.Automation.PSObject containing the fields stored in the event.
 
.LINK
Get-WinEvent
 
.EXAMPLE
Get-AspNetEvents WebServer
 
Returns any ASP.NET-related events from the WebServer Application event log that occurred today.
#>


[CmdletBinding()][OutputType([psobject])] Param(
# The name of the server on which the error occurred.
[Parameter(Position=0)][Alias('CN','Server')][string[]]$ComputerName = $env:COMPUTERNAME,
<#
Skip events older than this datetime.
Defaults to 00:00 today.
#>

[Parameter(Position=1)][DateTime]$After = ([DateTime]::Today),
<#
Skip events newer than this datetime.
Defaults to now.
#>

[Parameter(Position=2)][DateTime]$Before = ([DateTime]::Now),
# Include all event fields as properties.
[switch]$AllProperties
)
$IdFields = @{
    # 5, 9, 21 (1) Classic ASP errors, no fields
    # 1020 (0) IIS config failure, no fields
    # 1309 (30) late runtime issues
    1309 = @('EventCode','EventMessage','EventTime','EventTimeUtc','EventId','EventSequence','EventOccurrence',
        'EventDetailCode','AppDomain','TrustLevel','AppPath','AppLocalPath','MachineName','_','ProcessId','ProcessName',
        'AccountName','ExceptionType','ExceptionMessage','RequestUrl','RequestPath','UserHostAddress','User','IsAuthenticated',
        'AuthenticationType','ReqThreadAccountName','ThreadId','ThreadAccountName','IsImpersonating','StackTrace','CustomEventDetails')
    # 1310 (30) early configuration-type issues
    1310 = @('EventCode','EventMessage','EventTime','EventTimeUtc','EventId','EventSequence','EventOccurrence',
        'EventDetailCode','AppDomain','TrustLevel','AppPath','AppLocalPath','MachineName','_','ProcessId','ProcessName',
        'AccountName','ExceptionType','ExceptionMessage','RequestUrl','RequestPath','UserHostAddress','User','IsAuthenticated',
        'AuthenticationType','ReqThreadAccountName','ThreadId','ThreadAccountName','IsImpersonating','StackTrace','CustomEventDetails')
    # 1314 (24) access issues
    1314 = @('EventCode','EventMessage','EventTime','EventTimeUtc','EventId','EventSequence','EventOccurrence',
        'EventDetailCode','AppDomain','TrustLevel','AppPath','AppLocalPath','MachineName','_','ProcessId','ProcessName',
        'AccountName','RequestUrl','RequestPath','UserHostAddress','User','IsAuthenticated','AuthenticationType',
        'ThreadAccountName','CustomEventDetails')
    # 1315 (25) forms authentication failure
    1315 = @('EventCode','EventMessage','EventTime','EventTimeUtc','EventId','EventSequence','EventOccurrence',
        'EventDetailCode','AppDomain','TrustLevel','AppPath','AppLocalPath','MachineName','_','ProcessId','ProcessName',
        'AccountName','RequestUrl','RequestPath','UserHostAddress','User','IsAuthenticated','AuthenticationType',
        'ThreadAccountName','CustomEventDetails')
    # 1316 (31) session state failure
    1316 = @('EventCode','EventMessage','EventTime','EventTimeUtc','EventId','EventSequence','EventOccurrence',
        'EventDetailCode','AppDomain','TrustLevel','AppPath','AppLocalPath','MachineName','_','ProcessId','ProcessName',
        'AccountName','ExceptionType','ExceptionMessage','RequestUrl','RequestPath','UserHostAddress','User','IsAuthenticated',
        'AuthenticationType','ReqThreadAccountName','ThreadId','ThreadAccountName','IsImpersonating','StackTrace','CustomEventDetails')
    # 1325 (1) serious low-level stuff, no fields
}
$order =
    if($AllProperties) { @('MachineName','EventTime','EventTimeUtc','LogTime','EntryType','AppPath','ExceptionType',
        'ExceptionMessage','AccountName','UserHostAddress','IsImpersonating','IsAuthenticated','AuthenticationType','User','TrustLevel',
        'CustomEventDetails','AppLocalPath','RequestUrl','RequestPath','AppDomain','Source','EventCode','EventDetailCode','EventMessage',
        'EventOccurrence','EventSequence','EventId','ProcessName','ProcessId','ThreadId','ThreadAccountName','ReqThreadAccountName',
        'StackTrace') }
    else {  @('MachineName','EventTime','EventTimeUtc','LogTime','EntryType','AppPath','ExceptionType','ExceptionMessage','AccountName',
        'UserHostAddress','IsImpersonating','IsAuthenticated','CustomEventDetails') }
$RemoveFields= '_','ThreadAccountName','ReqThreadAccountName' # blank or redundant fields
$BoolFields= 'IsAuthenticated','IsImpersonating'
$IntFields= 'EventOccurrence','EventSequence','EventCode','EventDetailCode','ProcessId','ThreadId'
$query = [xml]@"
<QueryList>
    <Query Id="0" Path="Application">
        <Select Path="Application">*[System[Provider[@Name='Active Server Pages'
                                                     or @Name='ASP.NET 2.0.50727.0'
                                                     or @Name='ASP.NET 4.0.30319.0']
                                            and TimeCreated[@SystemTime &gt;= '$(Get-Date $After.ToUniversalTime() -Format yyyy-MM-ddTHH:mm:ss.000Z)'
                                                            and @SystemTime &lt;= '$(Get-Date $Before.ToUniversalTime() -Format yyyy-MM-ddTHH:mm:ss.999Z)']]]</Select>
        <Suppress Path="Application">*[System[EventID=1017
                                              or EventID=1019
                                              or EventID=1023
                                              or EventID=1025
                                              or EventID=1076
                                              or EventID=1077]]</Suppress>
    </Query>
</QueryList>
"@

Write-Verbose $query.OuterXml
$ComputerName |
    ForEach-Object {Get-WinEvent $query -CN $_} |
    ForEach-Object {
        $fields = @{EntryType=$_.LevelDisplayName;Source=$_.ProviderName}
        if($_.Properties.Count -lt 2 -or !$IdFields.Contains($_.Id))
        { # not structured nicely
            Write-Verbose "Unstructured:`n$($_.Message)"
            if($_.Message -match '(?m)^Application ID: (?<AppId>.+)$'){$fields.AppId=$Matches.AppId.TrimEnd()}
            if($_.Message -match '(?m)^Process ID: (?<ProcessId>.+)$'){$fields.ProcessId=[int]$Matches.ProcessId.TrimEnd()}
            if($_.Message -match '(?m)^Exception: (\w+\.)*(?<ExceptionType>\w+)\s*$'){$fields.ExceptionType=$Matches.ExceptionType}
            if($_.Message -match '(?m)^Message: (?<ExceptionMessage>.+)$'){$fields.ExceptionMessage=$Matches.ExceptionMessage.TrimEnd()}
            if($_.Message -match '(?ms)^StackTrace: (?<StackTrace>.+)$'){$fields.StackTrace=$Matches.StackTrace.TrimEnd()}
        }
        else
        {
            $values = $_.Properties |Select-Object -ExpandProperty Value
            $names = $IdFields[$_.Id]
            if($values.Length -gt $names.Length) { Write-Warning ('Unexpected field values: {0} > {1}' -f $values.Length,$names.Length) }
            0..($values.Length-1) |ForEach-Object {[void]$fields.Add($names[$_],$values[$_].TrimEnd())}
            $RemoveFields |ForEach-Object {$fields.Remove($_)}
            $BoolFields |ForEach-Object {$fields[$_]=[bool]$fields[$_]}
            $IntFields |ForEach-Object {$fields[$_]=[int]$fields[$_]}
            $fields.RequestUrl= [uri]$fields.RequestUrl
            $fields.EventTime= [datetime]::Parse($fields.EventTime,$null,[Globalization.DateTimeStyles]::AssumeLocal)
            if($AllProperties -or $fields.EventTime -ne $_.TimeCreated) {$fields.LogTime = $_.TimeCreated}
            $fields.EventTimeUtc= [datetime]::Parse($fields.EventTimeUtc,$null,[Globalization.DateTimeStyles]::AssumeUniversal)
            if(!$AllProperties -and $fields.EventTime -eq $fields.EventTimeUtc) {$fields.Remove('EventTimeUtc')}
            if($fields.ExceptionMessage -and $fields.StackTrace)
            { $fields.ExceptionMessage= $fields.ExceptionMessage.Replace($fields.StackTrace,'').TrimEnd() } # don't need stack trace twice
        }
        if($fields.Count -eq 2) {return}
        $ordered = [ordered]@{}
        $order |Where-Object {$fields.ContainsKey($_)} |ForEach-Object {[void]$ordered.Add($_,$fields.$_)}
        $value = New-Object PSObject -Property $ordered
        $value.PSObject.TypeNames.Insert(0,'AspNetApplicationEventLogEntry')
        $value
    }

}

function Get-DotNetFrameworkVersions
{
<#
.SYNOPSIS
Determine which .NET Frameworks are installed on the requested system.
 
.OUTPUTS
System.Collections.Hashtable of semantic version names to version numbers
of .NET frameworks installed.
 
.FUNCTIONALITY
DotNet
 
.COMPONENT
Microsoft.Win32.RegistryKey
 
.EXAMPLE
Get-DotNetFrameworkVersions
 
Name Value
---- -----
v4.6.2+win10ann 4.6.1586
v3.5 3.5.30729.4926
v2.0.50727 2.0.50727.4927
v3.0 3.0.30729.4926
#>


[CmdletBinding()][OutputType([hashtable])] Param(
# The computer to list the installed .NET Frameworks for.
[Alias('CN','Server')][string]$ComputerName = $env:COMPUTERNAME
)

function Add-Version([string]$Name,[string]$Version)
{if($Version){@{$Name=[version]$Version}}else{@{}}}

$hklm = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey('LocalMachine',$ComputerName)
$ndp = $hklm.OpenSubKey('SOFTWARE\Microsoft\NET Framework Setup\NDP')

$versions = @{}
# get v1-v3.x
$ndp.GetSubKeyNames() |
    Where-Object {$_ -like 'v[123].*'} |
    ForEach-Object {
        $k = $ndp.OpenSubKey($_)
        $v = $k.GetValue('Version')
        Write-Verbose "Found '$_' subkey, version $v"
        $versions += Add-Version $_ $v
        [void]$k.Dispose(); $k = $null
    }

# get v4.x
$v4 = $ndp.OpenSubKey('v4\Full')
try
{
    [string]$release = $v4.GetValue('Release')
    Write-Verbose "v4 release $release"
    $name = [ordered]@{ # see https://msdn.microsoft.com/en-us/library/hh925568.aspx
        '528049' = 'v4.8'
        '528372' = 'v4.8+win10may2020'
        '528040' = 'v4.8+win10may2019'
        '461814' = 'v4.7.2'
        '461808' = 'v4.7.2+win10april2018'
        '461310' = 'v4.7.1'
        '461308' = 'v4.7.1+win10fcu'
        '460805' = 'v4.7'
        '460798' = 'v4.7+win10cu'
        '394806' = 'v4.6.2'
        '394802' = 'v4.6.2+win10ann'
        '394748' = 'v4.6.2-preview'
        '394747' = 'v4.6.2-preview'
        '394271' = 'v4.6.1'
        '394254' = 'v4.6.1+win10'
        '393297' = 'v4.6'
        '393295' = 'v4.6+win10'
        '379893' = 'v4.5.2'
        '378758' = 'v4.5.1'
        '378675' = 'v4.5.1+win8.1'
        '378389' = 'v4.5'
    }
    foreach($key in $name.Keys)
    {
        if($release -ge $key)
        {
            $versions += Add-Version $name.$key $v4.GetValue('Version')
            break
        }
    }
}
catch [ArgumentNullException]
{
    Write-Warning "Unable to open the version 4 sub key."
}
finally
{
    if($v4) {[void]$v4.Dispose(); $v4 = $null}
    [void]$hklm.Dispose(); $hklm = $null
    [void]$ndp.Dispose(); $ndp = $null
}

$versions

}

function Get-DotNetVersions
{
<#
.SYNOPSIS
Determine which .NET Core & Framework versions are installed.
 
.FUNCTIONALITY
DotNet
 
.LINK
Get-DotNetFrameworkVersions
 
.EXAMPLE
Get-DotNetVersions
 
|Implementation Version
|-------------- -------
|.NET Framework 4.8.4084
|.NET Core 3.1.19
|.NET 5.0.10
#>


[CmdletBinding()] Param()

foreach($v in (Get-DotNetFrameworkVersions).GetEnumerator())
{
    [pscustomobject]@{
        Implementation = '.NET Framework'
        Version        = $v.Value
    }
}
try
{
    #TODO: Add or replace dependencies.
    Use-Command.ps1 dotnet $env:ProgramFiles\dotnet\dotnet.exe -Fail
    foreach($v in dotnet --list-runtimes)
    {
        $name,$version,$location = $v -split ' ',3
        if($name -ne 'Microsoft.NETCore.App') {continue}
        [pscustomobject]@{
            Implementation = if($version -like '5.*'){'.NET'}else{'.NET Core'}
            Version        = $version
        }
    }
}
catch{Write-Warning "Unable to enumerate .NET versions: $_"}

}

function Get-IisLog
{
<#
.SYNOPSIS
Easily query IIS logs.
 
.DESCRIPTION
This allows searching through various versions of IIS logs for matches to date ranges, URI path,
IP address, username, &c. and returns each entry as an object, with the Time converted to localtime.
 
.OUTPUTS
System.Management.Automation.PSObject[] with properties from the log file
for each request found:
 
* Time: DateTime of the request.
* Server: Address of the server.
* Filename: Which log file the request was found in.
* Line: The line of the log file containing the request detail.
* IpAddr: The client address.
* Username: The username of an authenticated request.
* UserAgent: The browser identification string send by the client.
* Method: The HTTP verb used by the request.
* UriPath: The location on the web server requested.
* Query: The GET query parameters requested.
* Referrer: The location that linked to this request, if provided.
* StatusCode: The numeric HTTP status code.
* Status: The HTTP success/error code of the response.
* SubStatusCode: The numeric IIS status code that subdivides HTTP status values.
* SubStatus: The HTTP status and IIS sub-status as a .-separated string.
* WinStatusCode: The Windows status code, as a number.
* WinStatus: The Windows status code, as a Win32Exception value.
 
.COMPONENT
LogParser
 
.LINK
ConvertFrom-Csv
 
.LINK
https://www.microsoft.com/download/details.aspx?id=24659
 
.LINK
https://docs.microsoft.com/windows/win32/debug/system-error-codes
 
.LINK
https://support.microsoft.com/help/943891/the-http-status-code-in-iis-7-0-iis-7-5-and-iis-8-0
 
.LINK
https://docs.microsoft.com/dotnet/api/system.net.http.httpresponsemessage.statuscode
 
.LINK
https://docs.microsoft.com/dotnet/api/system.componentmodel.win32exception
 
.EXAMPLE
Get-IisLog -LogDirectory \\Server\c$\inetpub\logs\LogFiles\W3SVC1 -After 2014-03-30 -UriPathLike '/WebApp/%' |select -First 1
 
Time : 2014-03-31 15:56:45
Server : 192.168.1.99:80
Filename : ex140331.log
Line : 121555
IpAddr : 192.168.1.199
Username :
UserAgent : Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/6.0; SLCC2; .NET CLR
2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E;
InfoPath.3)
Method : GET
UriPath : /WebApp/
Query :
Referrer :
StatusCode : 401
Status : Unauthorized
SubStatusCode : 2
SubStatus : Logon failed due to server configuration.
WinStatusCode : 5
WinStatus : Access is denied
#>


[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases','',
Justification='This script sets up and uses logparser.')]
[CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param(
# Attempts to use the LogFiles$ share of the computers listed as the log directory.
[Parameter(ParameterSetName='Server')][Alias('Server','CN')][string[]] $ComputerName,
# The directory(ies) containing the log files to query.
[Parameter(ParameterSetName='Directory')][Alias('Dir')][IO.DirectoryInfo[]] $LogDirectory = $PWD.ProviderPath,
# The minimum datetime to query.
[Parameter(Position=1)][datetime] $After = [datetime]::MinValue,
# The maximum datetime to query.
[Parameter(Position=2)][datetime] $Before = <# the max logparser date value #> '8191-12-31',
# The client IP address(es) to restrict the query to.
[Alias("ClientIP")][string[]] $IpAddr,
# The username to restrict the search to.
[string[]] $Username,
# The HTTP (major) status to restrict the search to.
[int[]] $Status,
# The HTTP method (GET or POST, &c) to restrict the search to.
[Microsoft.PowerShell.Commands.WebRequestMethod[]] $Method,
# A "like" pattern to match against the requested URI stem/path.
[string] $UriPathLike,
# A "like" pattern to match against the query string.
[string] $QueryLike,
# A "like" pattern to match against the HTTP referrer.
[Alias("RefererLike")][string] $ReferrerLike,
<#
The format the logs are written in:
* IIS: The old proprietary IIS log format.
* IISW3C: The (transitional?) W3C extended log file format.
* W3C: The newer W3C extended log file format.
#>

[ValidateSet('IIS','IISW3C','W3C')][string] $LogFormat = 'W3C'
)

#TODO: Add or replace dependencies.
Use-Command.ps1 logparser "${env:ProgramFiles(x86)}\Log Parser 2.2\LogParser.exe" `
    -msi http://download.microsoft.com/download/f/f/1/ff1819f9-f702-48a5-bbc7-c9656bc74de8/LogParser.msi

filter Format-DateTimeLiteral([Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][datetime]$DateTime)
{
    return "to_timestamp('$(Get-Date $DateTime -Format 'yyyy-MM-dd HH:mm:ss')','yyyy-MM-dd HH:mm:ss')"
}

$logRowName = @{
    IIS    = 'LogRow'
    IISW3C = 'LogRow'
    W3C    = 'RowNumber'
}

$iisSubStatus = @{
    '400.1' = 'Invalid Destination Header.'
    '400.2' = 'Invalid Depth Header.'
    '400.3' = 'Invalid If Header.'
    '400.4' = 'Invalid Overwrite Header.'
    '400.5' = 'Invalid Translate Header.'
    '400.6' = 'Invalid Request Body.'
    '400.7' = 'Invalid Content Length.'
    '400.8' = 'Invalid Timeout.'
    '400.9' = 'Invalid Lock Token.'
    '401.1' = 'Logon failed.'
    '401.2' = 'Logon failed due to server configuration.'
    '401.3' = 'Unauthorized due to ACL on resource.'
    '401.4' = 'Authorization failed by filter.'
    '401.5' = 'Authorization failed by ISAPI/CGI application.'
    '401.501' = 'Access Denied: Too many requests from the same client IP; Dynamic IP Restriction Concurrent request rate limit reached.'
    '401.502' = 'Forbidden: Too many requests from the same client IP; Dynamic IP Restriction Maximum request rate limit reached.'
    '401.503' = 'Access Denied: the IP address is included in the Deny list of IP Restriction'
    '401.504' = 'Access Denied: the host name is included in the Deny list of IP Restriction'
    '403.1' = 'Execute access forbidden.'
    '403.2' = 'Read access forbidden.'
    '403.3' = 'Write access forbidden.'
    '403.4' = 'SSL required.'
    '403.5' = 'SSL 128 required.'
    '403.6' = 'IP address rejected.'
    '403.7' = 'Client certificate required.'
    '403.8' = 'Site access denied.'
    '403.9' = 'Forbidden: Too many clients are trying to connect to the web server.'
    '403.10' = 'Forbidden: web server is configured to deny Execute access.'
    '403.11' = 'Forbidden: Password has been changed.'
    '403.12' = 'Mapper denied access.'
    '403.13' = 'Client certificate revoked.'
    '403.14' = 'Directory listing denied.'
    '403.15' = 'Forbidden: Client access licenses have exceeded limits on the web server.'
    '403.16' = 'Client certificate is untrusted or invalid.'
    '403.17' = 'Client certificate has expired or is not yet valid.'
    '403.18' = 'Cannot execute requested URL in the current application pool.'
    '403.19' = 'Cannot execute CGI applications for the client in this application pool.'
    '403.20' = 'Forbidden: Passport logon failed.'
    '403.21' = 'Forbidden: Source access denied.'
    '403.22' = 'Forbidden: Infinite depth is denied.'
    '403.501' = 'Forbidden: Too many requests from the same client IP; Dynamic IP Restriction Concurrent request rate limit reached.'
    '403.502' = 'Forbidden: Too many requests from the same client IP; Dynamic IP Restriction Maximum request rate limit reached.'
    '403.503' = 'Forbidden: the IP address is included in the Deny list of IP Restriction'
    '403.504' = 'Forbidden: the host name is included in the Deny list of IP Restriction'
    '404.0' = 'Not found.'
    '404.1' = 'Site Not Found.'
    '404.2' = 'ISAPI or CGI restriction.'
    '404.3' = 'MIME type restriction.'
    '404.4' = 'No handler configured.'
    '404.5' = 'Denied by request filtering configuration.'
    '404.6' = 'Verb denied.'
    '404.7' = 'File extension denied.'
    '404.8' = 'Hidden namespace.'
    '404.9' = 'File attribute hidden.'
    '404.10' = 'Request header too long.'
    '404.11' = 'Request contains double escape sequence.'
    '404.12' = 'Request contains high-bit characters.'
    '404.13' = 'Content length too large.'
    '404.14' = 'Request URL too long.'
    '404.15' = 'Query string too long.'
    '404.16' = 'DAV request sent to the static file handler.'
    '404.17' = 'Dynamic content mapped to the static file handler via a wildcard MIME mapping.'
    '404.18' = 'Querystring sequence denied.'
    '404.19' = 'Denied by filtering rule.'
    '404.20' = 'Too Many URL Segments'
    '404.501' = 'Not Found: Too many requests from the same client IP; Dynamic IP Restriction Concurrent request rate limit reached.'
    '404.502' = 'Not Found: Too many requests from the same client IP; Dynamic IP Restriction Maximum request rate limit reached.'
    '404.503' = 'Not Found: the IP address is included in the Deny list of IP Restriction'
    '404.504' = 'Not Found: the host name is included in the Deny list of IP Restriction'
    '500.0' = 'Module or ISAPI error occurred.'
    '500.11' = 'Application is shutting down on the web server.'
    '500.12' = 'Application is busy restarting on the web server.'
    '500.13' = 'Web server is too busy.'
    '500.15' = 'Direct requests for Global.asax are not allowed.'
    '500.19' = 'Configuration data is invalid.'
    '500.21' = 'Module not recognized.'
    '500.22' = 'An ASP.NET httpModules configuration does not apply in Managed Pipeline mode.'
    '500.23' = 'An ASP.NET httpHandlers configuration does not apply in Managed Pipeline mode.'
    '500.24' = 'An ASP.NET impersonation configuration does not apply in Managed Pipeline mode.'
    '500.50' = 'A rewrite error occurred during RQ_BEGIN_REQUEST notification handling. A configuration or inbound rule execution error occurred.'
    '500.51' = 'A rewrite error occurred during GL_PRE_BEGIN_REQUEST notification handling. A global configuration or global rule execution error occurred.'
    '500.52' = 'A rewrite error occurred during RQ_SEND_RESPONSE notification handling. An outbound rule execution occurred.'
    '500.53' = 'A rewrite error occurred during RQ_RELEASE_REQUEST_STATE notification handling. An outbound rule execution error occurred. The rule is configured to be executed before the output user cache gets updated.'
    '500.100' = 'Internal ASP error.'
    '502.1' = 'CGI application timeout.'
    '502.2' = 'Bad gateway: Premature Exit.'
    '502.3' = 'Bad Gateway: Forwarder Connection Error (ARR).'
    '502.4' = 'Bad Gateway: No Server (ARR).'
    '503.0' = 'Application pool unavailable.'
    '503.2' = 'Concurrent request limit exceeded.'
    '503.3' = 'ASP.NET queue full'
    '503.4' = 'FastCGI queue full'
}

filter ConvertTo-SqlString
{
    Param(
    [Parameter(Position=0,ValueFromPipeline=$true)][string] $Value
    )
    return "'$($Value -replace "'","''")'"
}

function ConvertTo-SqlStringList
{
    Param(
    [Parameter(Position=0)][string[]] $Values
    )
    $Local:OFS = ';'
    return "($($Values |ConvertTo-SqlString))"
}

filter ConvertTo-Enum
{
    Param(
    [Parameter(Position=0)][type] $EnumType,
    [Parameter(Position=1,ValueFromPipeline=$true)][string] $Name
    )
    $value = 0
    if([enum]::TryParse($EnumType,$Name,$true,[ref]$value)) {return $value}
    else {return $Name}
}

filter Convert-Result
{
    Param(
    [Parameter(ValueFromPipelineByPropertyName=$true)][datetime] $Time,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Server,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Filename,
    [Parameter(ValueFromPipelineByPropertyName=$true)][long] $Line,
    [Parameter(ValueFromPipelineByPropertyName=$true)][Net.IpAddress] $IpAddress,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Username,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $UserAgent,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Method,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $UriPath,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Query,
    [Parameter(ValueFromPipelineByPropertyName=$true)][uri] $Referrer,
    [Parameter(ValueFromPipelineByPropertyName=$true)][short] $StatusCode,
    [Parameter(ValueFromPipelineByPropertyName=$true)][uint32] $SubStatusCode,
    [Parameter(ValueFromPipelineByPropertyName=$true)][uint32] $WinStatusCode
    )
    $iisFullStatus,$subStatus = "$StatusCode.$SubStatusCode",''
    if($iisSubStatus.ContainsKey($iisFullStatus)) {$subStatus = $iisSubStatus[$iisFullStatus]}
    return [pscustomobject]@{
        Time          = $Time
        Server        = $Server
        Filename      = $Filename
        Line          = $Line
        IpAddress     = $IpAddress
        Username      = $Username
        UserAgent     = $UserAgent
        Method        = $Method |ConvertTo-Enum Microsoft.PowerShell.Commands.WebRequestMethod
        UriPath       = $UriPath
        Query         = $Query
        Referrer      = $Referrer
        StatusCode    = $StatusCode
        Status        = $StatusCode |ConvertTo-Enum Net.HttpStatusCode
        SubStatusCode = $SubStatusCode
        SubStatus     = $subStatus
        WinStatusCode = $WinStatusCode
        WinStatus     = [ComponentModel.Win32Exception][int]$WinStatusCode
    }
}

# get the log files
$LogDirectory =
    if ($ComputerName) { $ComputerName |ForEach-Object {"\\$_\LogFiles$"} }
    else { $LogDirectory |Resolve-Path |Select-Object -ExpandProperty Path }
$from = ' from ' +
    ((Get-ChildItem $LogDirectory -Filter *.log |
        Where-Object LastWriteTime -GE $After.AddDays(-1) |
        Where-Object CreationTime -LE $Before.AddDays(1) |
        Select-Object -ExpandProperty FullName) -join ',')
if($from -eq ' from ') { $from += $($LogDirectory|ForEach-Object {"$_\*.log"}) -join ',' }

# build the where clause
[string[]] $where = @(" where to_localtime(to_timestamp(date,time)) between $(Format-DateTimeLiteral $After)")
$where += Format-DateTimeLiteral $Before
if($IpAddr) { $where += "c-ip in $(ConvertTo-SqlStringList $IpAddr)" }
if($Username) { $where += "cs-username in $(ConvertTo-SqlStringList $Username)" }
if($Status) { $where += "sc-status in ($($Status -join ';'))" }
if($Method -and $Method.Length) { $where += "cs-method in $(ConvertTo-SqlStringList ($Method.ToUpperInvariant()))" }
if($UriPathLike) { $where += "cs-uri-stem like '$($UriPathLike -replace "'","''")'" }
if($QueryLike) { $where += "cs-uri-query like '$($QueryLike -replace "'","''")'" }
if($ReferrerLike) { $where += "cs(Referer) like '$($ReferrerLike -replace "'","''")'" }
$where = $where -join "`n and "

# assemble the SQL
$sql = @"
select to_localtime(to_timestamp(date,time)) as Time,
       case s-port when null then to_string(s-ip) else strcat(s-ip,strcat(':',to_string(s-port))) end as Server,
       extract_filename(LogFilename) as Filename,
       $($logRowName[$LogFormat]) as Line,
       coalesce(c-ip,'') as IpAddress,
       cs-username as Username,
       coalesce(replace_chr(cs(User-Agent),'+',' '),'') as UserAgent,
       cs-method as Method,
       coalesce(cs-uri-stem,'') as UriPath,
       coalesce(cs-uri-query,'') as Query,
       coalesce(cs(Referer),'') as Referrer,
       sc-status as StatusCode,
       sc-substatus as SubStatusCode,
       sc-win32-status as WinStatusCode
  into STDOUT
 $from
$where
"@


Write-Verbose $sql
logparser $sql -i:$LogFormat -o:TSV -headers:off -stats:off -q |
    ConvertFrom-Csv -Delimiter "`t" -Header 'Time','Server','Filename','Line','IpAddress',
        'Username','UserAgent','Method','UriPath','Query','Referrer',
        'StatusCode','Status','SubStatusCode','SubStatus','WinStatusCode','WinStatus' |
    Convert-Result

}

function Get-SimpleSchTasks
{
<#
.SYNOPSIS
Returns simple scheduled task info.
 
.FUNCTIONALITY
Scheduled Tasks
 
.EXAMPLE
Get-SimpleSchTasks -TaskPath \ -NonInteractive
 
Returns a simplified list of tasks for the local system that are not set to run interactively.
#>


[CmdletBinding()] Param(
# Specifies an array of one or more names of a scheduled task. You can use "*" for a wildcard character query.
[Parameter(Position=0)][string[]] $TaskName,
# Specifies an array of one or more paths for scheduled tasks in Task Scheduler namespace.
# You can use "*" for a wildcard character query. You can use \ for the root folder.
# To specify a full TaskPath you need to include the leading and trailing \ *.
# If you do not specify a path, the cmdlet uses the root folder.
[Parameter(Position=1)][string[]] $TaskPath,
# Exclude tasks that are set to run interactively, include only tasks with credentials set.
[switch] $NonInteractive
)
Begin
{
    filter Get-ComHandler
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipelineByPropertyName=$true)][string] $ClassId,
        [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Data
        )
        return "CLSID:$ClassId $Data"
    }
    filter Get-ExecAction
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipelineByPropertyName=$true)][string] $WorkingDirectory,
        [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Execute,
        [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Arguments
        )
        return '{0}> {1} {2}' -f (($WorkingDirectory ? $WorkingDirectory : "%SystemRoot%\system32"),
            ($Execute -replace '\A([^"].*\s.*)\z','"$1"'),$Arguments |
            ForEach-Object {[Environment]::ExpandEnvironmentVariables($_)})
    }
    filter Get-Run
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipeline=$true)][Microsoft.Management.Infrastructure.CimInstance] $Action
        )
        switch($Action.CimClass.CimClassName)
        {
            MSFT_TaskComHandlerAction {$Action |Get-ComHandler}
            MSFT_TaskExecAction {$Action |Get-ExecAction}
            default {"${_}: $($Action |Select-Object -ExcludeProperty Id,PSComputerName,Cim* |ConvertTo-Json -Compress -Depth 1)"}
        }
    }
    function Get-Weekdays
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0)][ushort] $DaysOfWeek
        )
        if($DaysOfWeek -band 0b1) {[dayofweek]::Sunday}
        if($DaysOfWeek -band 0b10) {[dayofweek]::Monday}
        if($DaysOfWeek -band 0b100) {[dayofweek]::Tuesday}
        if($DaysOfWeek -band 0b1000) {[dayofweek]::Wednesday}
        if($DaysOfWeek -band 0b10000) {[dayofweek]::Thursday}
        if($DaysOfWeek -band 0b100000) {[dayofweek]::Friday}
        if($DaysOfWeek -band 0b1000000) {[dayofweek]::Saturday}
    }
    function Get-NextWeekday
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0,Mandatory=$true)][datetime] $FromDateTime,
        [Parameter(Position=1,Mandatory=$true)][dayofweek] $DayOfWeek
        )
        $dow = $FromDateTime.DayOfWeek
        return $FromDateTime.AddDays(($dow - $DayOfWeek) + ($dow -gt $DayOfWeek ? 0 : 7))
    }
    filter Get-Weekly
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipelineByPropertyName=$true)][string] $StartBoundary,
        [Parameter(ValueFromPipelineByPropertyName=$true)][ushort] $DaysOfWeek,
        [Parameter(ValueFromPipelineByPropertyName=$true)][ushort] $WeeksInterval
        )
        Get-Weekdays $DaysOfWeek |
            ForEach-Object {'R/{0:yyyy-MM-ddTHH:mm:ss}/P{1}W' -f (Get-NextWeekday $StartBoundary $_),$WeeksInterval}
    }
    filter Get-Timing
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipelineByPropertyName=$true)][string] $StartBoundary,
        [Parameter(ValueFromPipelineByPropertyName=$true)][Microsoft.Management.Infrastructure.CimInstance] $Repetition
        )
        if(!$Repetition.Interval) {$StartBoundary}
        else {'R/{0:yyyy-MM-ddTHH:mm:ss}/{1}' -f $StartBoundary,$Repetition.Interval}
    }
filter Get-StateChange
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipelineByPropertyName=$true)][string] $UserId,
        [Parameter(ValueFromPipelineByPropertyName=$true)][uint] $StateChange
        )
        $username = "$($UserId ? $UserId : 'any user')"
        switch($StateChange)
        {
            1 {"On local connection to user session of $username"}
            2 {"On local disconnect to user session of $username"}
            3 {"On remote connection to user session of $username"}
            4 {"On remote disconnect from user session of $username"}
            7 {"On workstation lock of $username"}
            8 {"On workstation unlock of $username"}
            default {"On state change #$_ of $username"}
        }
    }
    filter Format-EventQuery
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Path,
        [Parameter(ValueFromPipelineByPropertyName=$true)][string] $InnerText
        )
        return "On event - Log: $Path, Source: $InnerText"
    }
    filter Get-EventQuery
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipelineByPropertyName=$true)][xml] $Subscription
        )
        if(!$Subscription) {return}
        Select-Xml -Xml $Subscription -XPath "/QueryList/Query/Select" |
            Select-Object -ExpandProperty Node |
            Format-EventQuery
    }
    filter Get-Schedule
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipeline=$true)][Microsoft.Management.Infrastructure.CimInstance] $Trigger
        )
        if(!$Trigger -or !$Trigger.Enabled) {return}
        switch($Trigger.CimClass.CimClassName)
        {
            MSFT_TaskWeeklyTrigger {$Trigger |Get-Weekly}
            MSFT_TaskTimeTrigger {$Trigger |Get-Timing}
            MSFT_TaskDailyTrigger {'R/{0:yyyy-MM-ddTHH:mm:ss}/P1D' -f $Trigger.StartBoundary}
            MSFT_TaskLogonTrigger {"At log on of $($Trigger.UserId ?? 'any user')"}
            MSFT_TaskBootTrigger {'At startup'}
            MSFT_TaskIdleTrigger {'On idle'}
            MSFT_TaskSessionStateChangeTrigger {$Trigger |Get-StateChange}
            MSFT_TaskRegistrationTrigger {'When the task is created or modified'}
            MSFT_TaskEventTrigger {$Trigger |Get-EventQuery}
            MSFT_TaskTrigger {'Monthly or task setting or custom (ambiguous)'}
            default {$Trigger |Get-Member -Type Properties |Write-Debug; $_}
        }
    }
    filter Get-SimpleTask
    {
        [CmdletBinding()] Param(
        [Parameter(ValueFromPipeline=$true)][Microsoft.Management.Infrastructure.CimInstance] $Task
        )
        $info = Get-ScheduledTaskInfo -TaskName $Task.TaskName -TaskPath $task.TaskPath
        return [pscustomobject]@{
            TaskName       = $Task.TaskName
            Enabled        = $Task.{Settings}?.Enabled
            State          = $Task.State
            User           = $Task.Principal.UserId
            LastRunTime    = $info.LastRunTime
            LastTaskResult = $info.LastTaskResult
            Run            = $Task.Actions |Get-Run
            Schedule       = $Task.Triggers |Get-Schedule
            LastResult     = [ComponentModel.Win32Exception][uint]$info.LastTaskResult
            ComputerName   = $env:COMPUTERNAME
        }
    }
}
Process
{
    $ScheduledTask = @{}
    if($TaskName) {$ScheduledTask['TaskName'] = $TaskName}
    if($TaskPath) {$ScheduledTask['TaskPath'] = $TaskPath}
    $tasks = Get-ScheduledTask @ScheduledTask
    if($NonInteractive) {$tasks = $tasks |Where-Object {$_.Principal.LogonType -eq 1}}
    $tasks |Get-SimpleTask
}

}

function Get-SystemDetails
{
<#
.SYNOPSIS
Collects some useful system hardware and operating system details via CIM.
 
.OUTPUTS
System.Management.Automation.PSCustomObject with properties about the computer:
 
* Name: The computer name.
* Status: The reported computer status name.
* Manufacturer: The reported computer manufacturer name.
* Model: The reported computer model name.
* PrimaryOwnerName: The reported name of the owner of the computer, if available.
* Memory: The reported memory in the computer, and amount unused.
* OperatingSystem: The name and type of operating system used by the computer.
* Processors: CPU hardware details.
* Video: Video controller hardware name.
* Drives: Storage drives found on the computer.
* Shares: The file shares configured, if any.
* NetVersions: The versions of .NET on the system.
 
.FUNCTIONALITY
System and updates
 
.LINK
https://en.wikipedia.org/wiki/List_of_Microsoft_Windows_versions
 
.LINK
https://vdc-repo.vmware.com/vmwb-repository/dcr-public/3d076a12-29a2-4d17-9269-cb8150b5a37f/8b5969e2-1a66-4425-af17-feff6d6f705d/doc/class_CIM_OperatingSystem.html
 
.LINK
Get-CimInstance
 
.EXAMPLE
Get-SystemDetails
 
Name : DEEPTHOUGHT
Status : OK
Manufacturer : Microsoft Corporation
Model : Surface Pro 4
PrimaryOwnerName :
Memory : 3.93 GiB (25.68 % free)
OperatingSystem : Microsoft Windows 10 Pro
OSVersion : 10.0.14393
OSUpdate : 1607
OSType : WINNT
OSArchitecture : 64-bit
Processors : Intel(R) Core(TM) i5-6300U CPU @ 2.40GHz
Video : NVIDIA GeForce GTX 670
Drives : C: 118 GiB (31.47 % free)
Shares :
NetVersions : {v4.6.2+win10ann, v3.5, v2.0.50727, v3.0}
#>


[CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param()
$cs = Get-CimInstance CIM_ComputerSystem
$os = Get-CimInstance CIM_OperatingSystem
[pscustomobject]@{
    Name = $cs.Name
    Status = $cs.Status
    Manufacturer = $cs.Manufacturer
    Model = $cs.Model
    PrimaryOwnerName = $cs.PrimaryOwnerName
    Memory = (Format-ByteUnits $cs.TotalPhysicalMemory -si -dot 2) +
        " ($('{0:p}' -f (1KB*$os.FreePhysicalMemory/$cs.TotalPhysicalMemory)) free)"
    OperatingSystem = $os.Caption + ' ' + $os.CSDVersion
    OSVersion = $os.Version
    OSUpdate =
        if($os.OSType -eq 18 -and $os.Version -like '10.*')
        {
            switch($os.BuildNumber)
            {
                22621 {'22H2'}
                22000 {'22H1'}
                19044 {'21H2'}
                19043 {'21H1'}
                19042 {'20H2'}
                19041 {'2004'}
                18363 {'1909'}
                18362 {'1903'}
                17763 {'1809'}
                17134 {'1803'}
                16299 {'1709'}
                15063 {'1703'}
                14393 {'1607'}
                10586 {'1511'}
                10240 {'1507'}
            }
        }
    OSType =
        switch($os.OSType)
        {
            0  {'Unknown'}
            1  {$os.OtherTypeDescription}
            2  {'MACOS'}
            3  {'ATTUNIX'}
            4  {'DGUX'}
            5  {'DECNT'}
            6  {'Tru64 UNIX'}
            7  {'OpenVMS'}
            8  {'HPUX'}
            9  {'AIX'}
            10 {'MVS'}
            11 {'OS400'}
            12 {'OS/2'}
            13 {'JavaVM'}
            14 {'MSDOS'}
            15 {'WIN3x'}
            16 {'WIN95'}
            17 {'WIN98'}
            18 {'WINNT'}
            19 {'WINCE'}
            20 {'NCR3000'}
            21 {'NetWare'}
            22 {'OSF'}
            23 {'DC/OS'}
            24 {'Reliant UNIX'}
            25 {'SCO UnixWare'}
            26 {'SCO OpenServer'}
            27 {'Sequent'}
            28 {'IRIX'}
            29 {'Solaris'}
            30 {'SunOS'}
            31 {'U6000'}
            32 {'ASERIES'}
            33 {'HP NonStop OS'}
            34 {'HP NonStop OSS'}
            35 {'BS2000'}
            36 {'LINUX'}
            37 {'Lynx'}
            38 {'XENIX'}
            39 {'VM'}
            40 {'Interactive UNIX'}
            41 {'BSDUNIX'}
            42 {'FreeBSD'}
            43 {'NetBSD'}
            44 {'GNU Hurd'}
            45 {'OS9'}
            46 {'MACH Kernel'}
            47 {'Inferno'}
            48 {'QNX'}
            49 {'EPOC'}
            50 {'IxWorks'}
            51 {'VxWorks'}
            52 {'MiNT'}
            53 {'BeOS'}
            54 {'HP MPE'}
            55 {'NextStep'}
            56 {'PalmPilot'}
            57 {'Rhapsody'}
            58 {'Windows 2000'}
            59 {'Dedicated'}
            60 {'OS/390'}
            61 {'VSE'}
            62 {'TPF'}
            63 {'Windows (R) Me'}
            64 {'Caldera Open UNIX'}
            65 {'OpenBSD'}
            66 {'Not Applicable'}
            67 {'Windows XP'}
            68 {'z/OS'}
            69 {'Microsoft Windows Server 2003'}
            70 {'Microsoft Windows Server 2003 64-Bit'}
            71 {'Windows XP 64-Bit'}
            72 {'Windows XP Embedded'}
            73 {'Windows Vista'}
            74 {'Windows Vista 64-Bit'}
            75 {'Windows Embedded for Point of Service'}
            76 {'Microsoft Windows Server 2008'}
            77 {'Microsoft Windows Server 2008 64-Bit'}
        }
    OSArchitecture = $os.OSArchitecture
    Processors = (Get-CimInstance CIM_Processor |
        Select-Object -ExpandProperty Name |
        ForEach-Object {$_ -replace '\s{2,}',' '})
    Video = Get-CimInstance CIM_VideoController |Select-Object -ExpandProperty Name
    Drives = (Get-CimInstance CIM_StorageVolume |
        Where-Object {$_.DriveType -eq 3 -and $_.DriveLetter -and $_.Capacity} |
        Sort-Object DriveLetter |
        ForEach-Object {"$($_.DriveLetter) $(Format-ByteUnits $_.Capacity -si -dot 2) ($('{0:p}' -f ($_.FreeSpace/$_.Capacity)) free)"})
    Shares= (Get-CimInstance Win32_Share |
        Where-Object {$_.Type -eq 0} |
        ForEach-Object {"$($_.Name)=$($_.Path)"})
    NetVersions = Get-DotNetFrameworkVersions
}

}

function Measure-Caches
{
<#
.SYNOPSIS
Returns a list of matching cache directories, and their sizes, sorted.
 
.NOTES
A shocking number of apps don't seem to know how the Windows folder structure
works, writing cache data into the user profile's Roaming folder, which gets
recopied at every logon, which is extremely wrong.
 
This script is primarily intended to show the scope of the problem, but can
be used to measure other folder matches.
 
.FUNCTIONALITY
Files
 
.EXAMPLE
Measure-Caches |Format-Table -AutoSize
 
Path Size DirectorySize DirectorySizeOnDisk
---- ---- ------------- -------------------
c:\users\usernam\appdata\roaming\code\cachedextensionvsixs 669.3MB 701856767 701915136
c:\users\usernam\appdata\roaming\slack\service worker\cachestorage 444MB 465538811 469655552
c:\users\usernam\appdata\roaming\slack\cache 340.2MB 356697780 357654528
c:\users\usernam\appdata\roaming\slack\cache\cache_data 340.2MB 356697780 357650432
c:\users\usernam\appdata\roaming\code\cache\cache_data 323.3MB 338983271 339546112
c:\users\usernam\appdata\roaming\code\cache 323.3MB 338983271 339550208
c:\users\usernam\appdata\roaming\slack\code cache 282MB 295745766 301752320
c:\users\usernam\appdata\roaming\code\cacheddata 172.6MB 181008350 191647744
#>


[CmdletBinding()] Param(
# The root directory to search from.
[string] $Path = $env:APPDATA,
# The directory name pattern to match using -Like.
[string] $NamePattern = '*cache*'
)
Begin
{
    #TODO: Add or replace dependencies.
    Use-Command.ps1 du "$env:ChocolateyInstall\bin\du.exe" -cinst sysinternals
}
Process
{
    return Get-ChildItem $Path -Directory -Recurse |
        Where-Object Name -like $NamePattern |
        Select-Object -ExpandProperty FullName |
        ForEach-Object {du -ct -nobanner $_ |Select-Object -skip 1} |
        ConvertFrom-Csv -Delimiter "`t" -Header Path,CurrentFileCount,CurrentFileSize,FileCount,DirectoryCount,DirectorySize,DirectorySizeOnDisk |
        ForEach-Object {[pscustomobject]@{
            Path                = $_.Path
            #TODO: Add or replace dependencies.
            Size                = [long] $_.DirectorySize |Format-ByteUnits.ps1 -Precision 1
            DirectorySize       = [long] $_.DirectorySize
            DirectorySizeOnDisk = [long] $_.DirectorySizeOnDisk
        }} |
        Sort-Object DirectorySize -Descending
}

}

function Remove-LockyFile
{
<#
.SYNOPSIS
Removes a file that may be prone to locking.
 
.INPUTS
System.String containing the path of a file to delete (or rename if deleting fails).
 
.FUNCTIONALITY
Files
 
.EXAMPLE
Remove-LockyFile InUse.dll
 
(Tries to remove file, renames it if unable to, tries deleting at reboot as a last resort.)
#>


[CmdletBinding()][OutputType([void])] Param(
<#
Specifies a path to the items being removed. Wildcards are permitted.
The parameter name ("-Path") is optional.
#>

[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,ValueFromRemainingArguments=$true)]
[string]$Path
)
Begin
{
    try{[void][RebootFileAction]}catch{Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public class RebootFileAction
{
    [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Auto)]
    private static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);
    const int MOVEFILE_DELAY_UNTIL_REBOOT = 0x4;
    static public void Delete(string filename)
    {
        if(!MoveFileEx(filename,"",MOVEFILE_DELAY_UNTIL_REBOOT))
        { throw new InvalidOperationException(String.Format("Unable to mark {0} for deletion at reboot.",filename)); }
    }
}
'@
}
}
Process
{
    try { Remove-Item $Path -Force -ErrorAction Stop }
    catch
    {
        $NewPath = "$Path~$([guid]::NewGuid().Guid)"
        try
        {
            Move-Item $Path $NewPath -Force -ErrorAction Stop
            $Path = Resolve-Path $NewPath
            try { Remove-Item $Path -Force -ErrorAction Stop }
            catch { Write-Warning "Renamed file, but unable to remove $Path"; throw }
        }
        catch
        {
            [RebootFileAction]::Delete((Resolve-Path $Path))
            Write-Warning "File $Path has been marked for deletion at reboot."
        }
    }
}

}

function Repair-AppxPackages
{
<#
.SYNOPSIS
Re-registers all installed Appx packages.
 
.NOTES
Must be run in Windows PowerShell, apparently.
Do not run in Windows Terminal.
 
.EXAMPLE
Repair-AppxPackages
 
Repairs what Windows Store apps it can when run from PowerShell 5.1 (not in Windows Terminal) as admin.
#>


[CmdletBinding()] Param()

filter Repair-AppxPackage
{
    [CmdletBinding()] Param(
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Name,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $Version,
    [Parameter(ValueFromPipelineByPropertyName=$true)][string] $InstallLocation
    )
    try { Add-AppxPackage -DisableDevelopmentMode -Register "$InstallLocation\AppxManifest.xml" -ErrorAction Stop }
    catch { Write-Error "Error repairing '$Name' v$Version`nInstall location: $InstallLocation`n$_" }
}

Get-AppxPackage |Repair-AppxPackage

}

function Restore-SchTasks
{
<#
.SYNOPSIS
Imports from a single XML file into the local Scheduled Tasks.
 
.FUNCTIONALITY
Scheduled Tasks
 
.LINK
https://msdn.microsoft.com/library/windows/desktop/bb736357.aspx
 
.LINK
Select-Xml
 
.LINK
Get-Credential
 
.EXAMPLE
Restore-SchTasks
 
(Imports scheduled tasks from tasks.xml, prompting for passwords as needed.)
#>


[CmdletBinding()][OutputType([void])] Param(
# The file to import tasks from, as exported from Backup-SchTasks.
[Parameter(Position=0)][string] $Path = 'tasks.xml',
<#
A wildcard pattern to match task "paths" (including folders) to skip.
 
User tasks are usually just in the root, and generally machine-specific
tasks Microsoft automatically sets up are in folders so this is *\* by
default to exclude the weird magic tasks.
#>

[string] $Exclude = '*\*'
)
$credentials = @{}
$xmldecl = "<?xml version=`"1.0`" encoding=`"UTF-16`" ?>{0}{0}" -f [environment]::NewLine
$Script:PSDefaultParameterValues = @{'Select-Xml:Namespace'=@{task='http://schemas.microsoft.com/windows/2004/02/mit/task'}}
foreach($task in ((Get-Content $Path -Raw) -replace '(?<=\A|>)\s*</?Tasks>\s*(?=\S|\z)','') -split '(?<=</Task>)\s*?(?=<!--)')
{
    $xml = [xml]$task
    $name = (Select-Xml '/comment()' -Xml $xml).Node.Value.Trim().TrimStart('\')
    if($Exclude -and ($name -like $Exclude)) { Write-Verbose "Skipping task $name"; continue }
    else { Write-Verbose "Importing task $name" }
    Out-File "$name.xml" -Encoding unicode -InputObject "$xmldecl$task"  -Width ([int]::MaxValue)
    $logon = (Select-Xml '/task:Task/task:Principals/task:Principal[@id=''Author'']/task:LogonType' -Xml $xml).Node.InnerText
    if($logon -ne 'Password')
    {
        Write-Verbose "Importing logon type $logon"
        schtasks /Create /TN $name /XML "$name.xml"
    }
    else
    {
        $user = (Select-Xml '/task:Task/task:Principals/task:Principal[@id=''Author'']/task:UserId' -Xml $xml).Node.InnerText
        if(!$credentials.ContainsKey($user))
        {
            $cred = Get-Credential $user -Message "Please enter credentials to run '$name' job as"
            if(!$credentials.ContainsKey($cred.UserName)) { $credentials[$cred.UserName] = $cred }
        }
        if($user -ne $cred.UserName) { Write-Verbose "Importing to run as $($cred.UserName)" }
        schtasks /Create /RU $cred.UserName /RP ($cred.Password |ConvertFrom-SecureString -AsPlainText) /TN $name /XML "$name.xml"
    }
    Remove-Item "$name.xml"
}

}

function Restore-Workstation
{
<#
.SYNOPSIS
Restores various configuration files and exported settings from a ZIP file.
 
.LINK
Import-EdgeKeywords.ps1
 
.LINK
Import-SecretVault.ps1
 
.EXAMPLE
Restore-Workstation.ps1 COMPUTERNAME-20230304T125000.zip
 
Restores various settings from a backup file.
#>


#TODO: Add or replace dependencies.
[CmdletBinding()] Param(
[ValidateScript({Test-Path $_ -Type Leaf})][Parameter(Position=0,Mandatory=$true)][string] $Path
)

function Restore-Workstation
{
    Expand-Archive -Path $Path -DestinationPath ~ -Force
    Join-Path ~ edge-keywords.json -OutVariable file |Get-Content |ConvertFrom-Json |Import-EdgeKeywords.ps1
    Remove-Item $file
    Join-Path ~ secret-vault.json -OutVariable file |Get-Content |ConvertFrom-Json |Import-SecretVault.ps1
    Remove-Item $file
}

Restore-Workstation

}

function Set-SchTaskMsa
{
<#
.SYNOPSIS
Sets a Scheduled Task's runtime user as the given gMSA/MSA.
 
.FUNCTIONALITY
Scheduled Tasks
 
.INPUTS
An object with a TaskName string property.
 
.LINK
https://learn.microsoft.com/windows-server/identity/ad-ds/manage/group-managed-service-accounts/group-managed-service-accounts/group-managed-service-accounts-overview
 
.LINK
Set-ScheduledTask
 
.LINK
New-ScheduledTaskPrincipal
 
.EXAMPLE
Set-SchTaskMsa 'Backup VSCode settings' automation
 
Sets the tasks running user to the "automation" managed service account.
#>


[CmdletBinding()] Param(
[Parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)][string] $TaskName,
[Parameter(Position=1,Mandatory=$true)][Alias('MSA','gMSA','UserId')][string] $ServiceAccount,
[switch] $HighestRunLevel
)
Process
{
    Set-ScheduledTask -TaskName $TaskName -Principal (New-ScheduledTaskPrincipal -UserID $ServiceAccount `
        -LogonType Password -RunLevel:($HighestRunLevel ? 'Highest' : 'Normal'))
}

}

function Set-TerminalProfile
{
<#
.SYNOPSIS
Adds or updates a Windows Terminal command profile.
 
.FUNCTIONALITY
Windows Terminal
 
.PARAMETER Type
The CLI application to configure.
 
.PARAMETER Name
The name of the profile.
 
.PARAMETER CommandLine
The command line to use in the profile.
 
.LINK
https://aka.ms/terminal-documentation
 
.LINK
https://aka.ms/terminal-profiles-schema
 
.LINK
https://gist.github.com/shanselman/4d954449914664024ee20ba10c2aaa0d
 
.LINK
https://learn.microsoft.com/en-us/windows/terminal/json-fragment-extensions
 
.LINK
https://github.com/microsoft/terminal/issues/1918#issuecomment-2452815871
 
.EXAMPLE
Set-TerminalProfile pwsh
 
Adds a default profile for PowerShell Core.
 
.EXAMPLE
Set-TerminalProfile fsi
 
Adds a default profile for F# Interactive.
 
.EXAMPLE
Set-TerminalProfile ssh servername 'ssh username@servername'
 
Adds an ssh profile named "servername", using the specified command line.
#>


[CmdletBinding()] Param()
DynamicParam
{
    #TODO: Add or replace dependencies.
    $Script:data = Get-Content ([io.path]::ChangeExtension($PSCommandPath, 'json')) -Raw |ConvertFrom-Json -AsHashtable
    $data.Keys |Add-DynamicParam.ps1 -Name Type -Type string -Position 0 -Mandatory
    Add-DynamicParam.ps1 -Name Name -Type string -Position 1
    Add-DynamicParam.ps1 -Name CommandLine -Type string -Position 2
    $DynamicParams
}
Begin
{
    function Initialize-Variables
    {
        $Script:settings = Join-Path $env:LOCALAPPDATA Packages Microsoft.WindowsTerminal_8wekyb3d8bbwe LocalState settings.json
        if(!(Test-Path $Script:settings -Type Leaf)) {throw "Could not find $Script:settings"}
        #TODO: Add or replace dependencies.
        $Script:profiles = Select-Json.ps1 -JsonPointer /profiles/list -Path $Script:settings
    }

    function Get-TerminalProfileByGuid
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0,Mandatory=$true)][guid] $Guid
        )
        return $Script:profiles |Where-Object {$_.ContainsKey('guid')} |Where-Object guid -eq $Guid.ToString('B')
    }

    function Get-TerminalProfileBySource
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0,Mandatory=$true)][string] $Source
        )
        return $Script:profiles |Where-Object {$_.ContainsKey('source')} |Where-Object source -eq $Source
    }

    function Get-TerminalProfileByName
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0,ValueFromRemainingArguments=$true)][AllowNull()][string[]] $Name
        )
        return $Script:profiles |Where-Object {$_.ContainsKey('name')} |Where-Object name -in $Name |Select-Object -First 1
    }

    function Get-TerminalProfile
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0,Mandatory=$true)][string] $Type,
        [Parameter(Position=1,Mandatory=$true)][AllowEmptyString()][string] $Name
        )
        switch($Type)
        {
            pwsh { return (Get-TerminalProfileByGuid 574e775e-4f2a-5b96-ac1e-a2962a402336) `
                ?? (Get-TerminalProfileBySource Windows.Terminal.PowershellCore) `
                ?? (Get-TerminalProfileByName PowerShell 'PowerShell Core' 'PowerShell 7') `
                ?? $Script:data[$_] }
            powershell { return (Get-TerminalProfileByGuid 61c54bbd-c2c6-5271-96e7-009a87ff44bf) `
                ?? (Get-TerminalProfileByName 'Windows PowerShell') `
                ?? $Script:data[$_] }
            wsl { return (Get-TerminalProfileByGuid 2c4de342-38b7-51cf-b940-2309a097f518) `
                ?? (Get-TerminalProfileBySource Windows.Terminal.Wsl) `
                ?? (Get-TerminalProfileByName Ubuntu) `
                ?? $Script:data[$_] }
            azcs { return (Get-TerminalProfileByGuid b453ae62-4e3d-5e58-b989-0a998ec441b8) `
                ?? (Get-TerminalProfileBySource Windows.Terminal.Azure) `
                ?? (Get-TerminalProfileByName Azure Cloud Shell) `
                ?? $Script:data[$_] }
            default
            {
                if(!$Name) {return $Script:data[$_]}
                else {return (Get-TerminalProfileByName $Name) ?? $Script:data[$_]}
            }
        }
    }

    function Initialize-TerminalProfile
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0)][AllowEmptyString()][string] $Name,
        [Parameter(Position=1)][AllowEmptyString()][string] $CommandLine,
        [Parameter(ValueFromPipeline=$true,Mandatory=$true)][pscustomobject] $InputObject
        )
        if(!$InputObject.ContainsKey('guid') -or !$InputObject['guid']) { $InputObject['guid'] = (New-Guid).ToString('B') }
        if($Name) { $InputObject['name'] = $Name }
        if($CommandLine) { $InputObject['commandline'] = $CommandLine }
        if($InputObject.ContainsKey('backgroundImage') -and $InputObject['backgroundImage'] -notlike '*:*')
        {
            $InputObject['backgroundImage'] = Join-Path $PSScriptRoot logos $InputObject['backgroundImage']
        }
        if($InputObject.ContainsKey('icon') -and $InputObject['icon'] -notlike '*:*')
        {
            $InputObject['icon'] = Join-Path $PSScriptRoot logos $InputObject['icon']
        }
        return $InputObject
    }

    function Update-TerminalProfile
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0,Mandatory=$true)][string] $Type,
        [Parameter(Position=1)][string] $Name,
        [Parameter(Position=2)][string] $CommandLine
        )
        Initialize-Variables
        $termprofile = Get-TerminalProfile $Type $Name |Initialize-TerminalProfile $Name $CommandLine
        Write-Information 'Found profile:' #-fg DarkGray
        $termprofile |Format-Table -AutoSize |Out-String |Write-Information #-fg Gray
        for($position = 0; $position -lt $Script:profiles.Count; $position++)
        {
            if($Script:profiles[$position]['guid'] -eq $termprofile['guid']) {break}
        }
        Write-Information "Setting position $position"
        Copy-Item $Script:settings ([io.path]::ChangeExtension($Script:settings, (Get-Date -Format yyyyMMdd\THHmmss)))
        #TODO: Add or replace dependencies.
        Set-Json.ps1 -JsonPointer "/profiles/list/$position" -PropertyValue $termprofile -Path $Script:settings
    }
}
Process { Update-TerminalProfile @PSBoundParameters }

}

function Test-LockedFile
{
<#
.SYNOPSIS
Returns true if the specified file is locked.
 
.INPUTS
Object with System.String property named Path containing the path to a file to test.
 
.OUTPUTS
System.Boolean indicating whether the file is locked.
 
.FUNCTIONALITY
Files
#>


[CmdletBinding()][OutputType([bool])] Param(
# A path to a file to test.
[Parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('FullName')][string] $Path
)
Begin
{
    try{[void][RebootFileAction]}catch{Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public class RebootFileAction
{
    [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Auto)]
    private static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName, int dwFlags);
    const int MOVEFILE_DELAY_UNTIL_REBOOT = 0x4;
    static public void Delete(string filename)
    {
        if(!MoveFileEx(filename,"",MOVEFILE_DELAY_UNTIL_REBOOT))
        { throw new InvalidOperationException(String.Format("Unable to mark {0} for deletion at reboot.",filename)); }
    }
}
'@
}
}
Process
{
    #TODO: ...
}

}

function Test-WindowsTerminal
{
<#
.SYNOPSIS
Returns true if PowerShell is running within Windows Terminal.
 
.FUNCTIONALITY
Windows Terminal
 
.LINK
https://aka.ms/terminal-documentation
 
.EXAMPLE
Test-WindowsTerminal
 
True
#>


[CmdletBinding()][OutputType([bool])] Param()
if(!$IsWindows) {return $false}
if($env:WT_SESSION) {return $true}
for($process = Get-Process -Id $PID; $process; $process = $process.Parent)
{
    if($process.ProcessName -eq 'WindowsTerminal') {return $true}
}
return $false

}
Export-ModuleMember -Function Backup-SchTasks,Backup-Workstation,Convert-ChocolateyToWinget,ConvertFrom-CimInstance,ConvertTo-LogParserTimestamp,Copy-SchTasks,Find-ProjectPackages,Get-ADServiceAccountInfo,Get-ADUserStatus,Get-AspNetEvents,Get-DotNetFrameworkVersions,Get-DotNetVersions,Get-IisLog,Get-SimpleSchTasks,Get-SystemDetails,Measure-Caches,Remove-LockyFile,Repair-AppxPackages,Restore-SchTasks,Restore-Workstation,Set-SchTaskMsa,Set-TerminalProfile,Test-LockedFile,Test-WindowsTerminal