obs-powershell.build.ps1

#requires -Module PipeScript


# The WebSocket is nice enough to provide it's documentation in JSON
$obsWebSocketProtocol = Invoke-RestMethod https://raw.githubusercontent.com/obsproject/obs-websocket/master/docs/generated/protocol.json

# This will save us lots of time and effort for parsing


# We'll want to translate OBS WebSocket requests into commands.

# We'll want a prefix on all commands.
$modulePrefix = 'OBS'
# And we'll want a hashtable of replacements

# A number of request types start with a verb name already
$verbReplacements = @{}
# these are easy
foreach ($easyVerb in 'Get', 'Set','Open', 'Close', 'Start', 'Stop', 'Resume','Remove','Save','Send') {
    $verbReplacements[$easyVerb] = $easyVerb
}
# A number of other words cases should also infer the verb
$verbReplacements += [Ordered]@{    
    'Toggle'      = 'Switch' # Toggle infers switch
    'Create'      = 'Add'    # Create infers add
    'Duplicate'   = 'Copy'   # Duplicate infers copy
    'Broadcast'   = 'Send'   # Broadcast infers Send
    'List'        = 'Get'    # List infers get (but may cause duplication problems)
}

# Construct a pair of regex to see if something starts or ends with our replacements
$startsWithVerbRegex = "^(?>$($verbReplacements.Keys -join '|'))"
$endsWithVerbRegex   = "(?>$($verbReplacements.Keys -join '|'))$"

# We also want a pair of regexes to determine if a value has a min/max range.
$minRangeRestriction = "\>=\s{0,}(?<min>[\d\.-]+)"
$maxRangeRestriction = "\<=\s{0,}(?<max>[\d\.-]+)"

# Create an array to hold all of the functions we create
$obsFunctions = @()
# and files we build.
$filesBuilt   = @()

# And determine where we want to store them
$commandsPath = Join-Path $PSScriptRoot Commands
$requestsPath = Join-Path $commandsPath Requests

# (create the directory if it didn't already exist)
if (-not (Test-Path $requestsPath)) {
    $null = New-Item -ItemType Directory -Path $requestsPath -Force
}

$ToAlias = @{
    "Add-OBSSceneItem" = "Add-OBSSceneSource"
}

$PostProcess = @{
    "Save-OBSSourceScreenshot" = {
        Get-Item $paramCopy["imageFilePath"] |
            Add-Member NoteProperty InputName $paramCopy["SourceName"] -Force -PassThru  |
            Add-Member NoteProperty SourceName $paramCopy["SourceName"] -Force -PassThru |
            Add-Member NoteProperty ImageWidth $paramCopy["ImageWidth"] -Force -PassThru |
            Add-Member NoteProperty ImageHeight $paramCopy["ImageHeight"] -Force -PassThru
    }
}

# Declare the process block for all commands now
$obsFunctionProcessBlock = {

        # Create a copy of the parameters (that are part of the payload)
        $paramCopy = [Ordered]@{}
        # get a reference to this command
        $myCmd = $MyInvocation.MyCommand

        # Keep track of how many requests we have done of a given type
        # (this makes creating RequestIDs easy)
        if (-not $script:ObsRequestsCounts) {
            $script:ObsRequestsCounts = @{}
        }

        # Set my requestType to blank
        $myRequestType = ''
        # and indicate we are not expecting a response
        $responseExpected = $false
        # Then walk over this commands' attributes,
        foreach ($attr in $myCmd.ScriptBlock.Attributes) {
            if ($attr -is [Reflection.AssemblyMetadataAttribute]) {
                if ($attr.Key -eq 'OBS.WebSocket.RequestType') {
                    $myRequestType = $attr.Value # set the requestType,
                }
                elseif ($attr.Key -eq 'OBS.WebSocket.ExpectingResponse') {
                    # and determine if we are expecting a response.
                    $responseExpected = 
                        if ($attr.Value -eq 'false') {
                            $false   
                        } else { $true }
                }
            }
        }

        # Walk over each parameter
        :nextParam foreach ($keyValue in $PSBoundParameters.GetEnumerator()) {
            # and walk over each of it's attributes to see if it part of the payload
            foreach ($attr in $myCmd.Parameters[$keyValue.Key].Attributes) {
                # If the parameter is bound to part of the payload
                if ($attr -is [ComponentModel.DefaultBindingPropertyAttribute]) {
                    # copy it into our payload dicitionary.
                    $paramCopy[$attr.Name] = $keyValue.Value
                    # (don't forget to turn switches into booleans)
                    if ($paramCopy[$attr.Name] -is [switch]) {
                        $paramCopy[$attr.Name] = [bool]$paramCopy[$attr.Name]
                    }
                    if ($attr.Name -like '*path') {
                        $paramCopy[$attr.Name] =
                            "$($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($paramCopy[$attr.Name]))"
                    }
                    continue nextParam
                }
            }
        }
        
        # and make a request ID from that.
        $myRequestId = "$myRequestType.$([Guid]::newGuid())"
    
        # Construct the payload object
        $requestPayload = [Ordered]@{
            # It must include a request ID
            requestId = $myRequestId
            # request type
            requestType = $myRequestType
            # and optional data
            requestData = $paramCopy
        }

        if ($PassThru) {
            [PSCustomObject]$requestPayload
        } else {
            [PSCustomObject]$requestPayload | 
                Send-OBS
        }
}


# Walk over each type of request.
foreach ($obsRequestInfo in $obsWebSocketProtocol.requests) {   
    $requestType = $obsRequestInfo.RequestType
    $replacedRequestType = "$requestType"
    
    # Determine the function name
    $obsFunctionName =
        @(
        # If it started with something that inferred the verb
        if ($requestType -match $startsWithVerbRegex) {
            # replace the name
            $replacedRequestType = ($replacedRequestType -replace $startsWithVerbRegex)
            $verbReplacements[$matches.0] + '-' + $modulePrefix + $replacedRequestType
        }
        
        # If it ended with something that inferred the verb
        if ($requestType -cmatch $endsWithVerbRegex) {
            # replace the name again
            $replacedRequestType = ($replacedRequestType -creplace $endsWithVerbRegex)
            $verbReplacements[$matches.0] + '-' + $modulePrefix + $replacedRequestType
        }

        # If it didn't start or end with something that inferred a verb
        if ($requestType -notmatch $startsWithVerbRegex -and 
            $requestType -notmatch $endsWithVerbRegex) {
            # Use Send-
            "Send-${modulePrefix}${RequestType}"
        })[-1] # Pick the last output for from this set of options, as this will be the most changed.
    
    $obsFunctionParameters = [Ordered]@{}

    # Now we have to turn each field in the request into a parameter
    foreach ($requestField in $obsRequestInfo.requestFields) {
        $valueType = $requestField.valueType
        # Some field names contain periods, don't forget to get rid of those.
        $paramName = $requestField.valueName -replace '\.'

        # PowerShell parameters should start with uppercase letters, so fix that.
        $paramName = $paramName.Substring(0,1).ToUpper() + $paramName.Substring(1)

        $paramType = # map their parameter types to PowerShell parameter types
            if ($valueType -eq 'Boolean') { '[switch]'}
            elseif ($valueType -eq 'Number') { '[double]'}
            elseif ($valueType -eq 'Object') { '[PSObject]'}
            elseif ($valueType -eq 'Any') { '[PSObject]'}
            elseif ($valueType -eq 'String') { '[string]'}
            else { '' }
        
            # And declare a parameter
            $obsFunctionParameters[$paramName] = 
                @(                   
                    # Include the description
                    "<# $($requestField.ValueDescription) #>"
                    # Make sure to declare it as ValueFromPipelineByPropertyName
                    "[Parameter($(
                        # and mark it as mandtory if it's not optional.
                        if (-not $requestField.ValueOptional) { "Mandatory,"}
                    )ValueFromPipelineByPropertyName)]"

                    # Track the 'bound' property
                    "[ComponentModel.DefaultBindingProperty('$($requestField.valueName)')]"
                    # If there were range descriptions
                    if ($requestField.valueRestrictions) {
                        # determine the min/max
                        $rangeMin, $rangeMax = $null, $null

                        if ($requestField.valueRestrictions -match $minRangeRestriction) {
                            $rangeMin = [double]$matches.min
                        }
                        if ($requestField.valueRestrictions -match $maxRangeRestriction) {
                            $rangeMax = [double]$matches.max
                        }

                        # and write a [ValidateRange()]
                        if ($rangeMin -ne $null -or $rangeMax -ne $null) {
                            "[ValidateRange($($rangeMin),$(if ($rangeMax) {
                                $rangeMax
                            } elseif ([Math]::Round($rangeMin) -eq $rangeMin) {
                                "[int]::MaxValue"
                            } else {
                                "[double]::MaxValue"
                            }))]"

                        }
                    }
                    # Include the parameter type
                    $paramType
                    # and declare the parameter.
                    "`$$paramName"
                )
    }

    $obsFunctionParameters['PassThru'] = @(
        "# If set, will return the information that would otherwise be sent to OBS."
        "[Parameter(ValueFromPipelineByPropertyName)]"
        "[Alias('OutputRequest','OutputInput')]"
        "[switch]"
        '$PassThru'
    )

    $newFunctionAttributes = @(
        "[Reflection.AssemblyMetadata('OBS.WebSocket.RequestType', '$requestType')]"
        if ($obsRequestInfo.responseFields.Count) {
            "[Reflection.AssemblyMetadata('OBS.WebSocket.ExpectingResponse', `$true)]"
        }
        if ($ToAlias[$obsFunctionName]) {
            "[Alias('$($ToAlias[$obsFunctionName] -join "','")')]"
        }
    )
    

    $processBlock = if ($PostProcess[$obsFunctionName]) {
        [scriptblock]::Create(
            '' + $obsFunctionProcessBlock + [Environment]::Newline + $PostProcess[$obsFunctionName]
        )
    } else {
        $obsFunctionProcessBlock
    }
        

    $newFunc = 
    New-PipeScript -FunctionName $obsFunctionName -Parameter $obsFunctionParameters -Process $processBlock -Attribute $newFunctionAttributes -Synopsis "
$obsFunctionName : $requestType
"
 -Description @"
$($obsRequestInfo.description)


$obsFunctionName calls the OBS WebSocket with a request of type $requestType.
"@
 -Example @(
    if ($obsRequestInfo.requestFields.Count -eq 0) {
        "$obsFunctionName"
    }
) -Link @(
    "https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#$($requestType.ToLower())"
)

    
    # If no function was created, something went wrong
    if (-not $newFunc) {
        # so leave this no-op in here to be able to easily debug.
        $null = $null
    } else {
        # Otherwise, write the ful
        $outputPath = Join-Path $requestsPath "$obsFunctionName.ps1"
        $newFunc | Set-Content $outputPath -Encoding utf8
        $builtFile = Get-Item -LiteralPath $outputPath
        # and attach information about what was generated (in case of collision)
        $builtFile | 
            Add-Member NoteProperty OBSRequestType $obsRequestInfo.requestType -Force -PassThru |
            Add-Member NoteProperty OBSRequestInfo $obsRequestInfo -Force -PassThru |
            Add-Member NoteProperty Contents "$newFunc" -Force
        $filesBuilt += $builtFile
        $obsFunctions += $newFunc
    }
}


# Now group all the files
$filesBuilt | 
    Group-Object |    
    ForEach-Object {
        # If it only created it once
        if ($_.Count -eq 1) {
            return $_.Group # return it directly
        }

        # Otherwise, basically do a simplified rename on each file
        $groupOfDuplicates = $_.Group
        $groupInfo = $_
        # start by removing our current name
        Remove-Item $groupOfDuplicates[0].FullName
        # Then, for each duplicate
        foreach  ($file in $groupOfDuplicates) {
            
            # Figure out the name it thought it had
            $functionName = $file.Name -replace '\.ps1$'

            # And the underlying request type
            $requestType = $file.OBSRequestType

            # use that to re-determine the function name
            $newFunctionName = 
                if ($requestType -match $startsWithVerbRegex) {                    
                    $verbReplacements[$matches.0] + '-' +
                        $modulePrefix + ($RequestType -replace $startsWithVerbRegex)
                } else {
                    "Send-${modulePrefix}${RequestType}"
                }

            # and use that name to determine a new path
            $newPath =  Join-Path $file.Directory "$newFunctionName.ps1"

            # Then replace the function name within it's contents.
            $file.contents -replace $functionName, $newFunctionName |
                Set-Content -LiteralPath $newPath -Encoding utf8

            # And return the new file, with the same information set attached.
            Get-Item -LiteralPath $newPath |
                Add-Member NoteProperty OBSRequestType $file.requestType -Force -PassThru |
                Add-Member NoteProperty OBSRequestInfo $file.obsRequestInfo -Force -PassThru |
                Add-Member NoteProperty Contents $file.Contents -Force -PassThru
        }
    }