Public/Get-HDTConsoleApplicationChoice.ps1

function Get-HDTConsoleApplicationChoice {
    <#
        .SYNOPSIS
            An InstallApplications step's selection, and the applications the
            share offers.
 
        .DESCRIPTION
            MDT'S Install Application PAGE, where an administrator PICKS what a
            step installs rather than typing ids and finding out at the machine
            whether they spelled them right.
 
            THE STEP TYPE THAT HAD NO PAGE. Until this existed an
            InstallApplications step fell through to the generic Properties tab,
            where `selection` was a text box - so the ids were typed from memory,
            and a typo was not found until a deployment ran and
            Resolve-HDTApplicationOrder refused the WHOLE plan, on the machine in
            front of somebody. That is the same argument that put an
            application's Depends On behind a picker
            (Get-HDTConsoleDependencyChoice), and this is the other half of it.
 
            THERE ARE TWO ANSWERS, AND MDT ASKS WHICH. A step either installs
            what the technician chose - MDT's "Install multiple applications",
            reading a variable - or a fixed list the sequence names. The template
            writes `selection: '%HDTApplications%'` for the first, on purpose,
            because that is what a wizard answer or a rule fills. So this reports
            WHICH of the two the document is, and a page that only offered ticks
            would delete the indirection the sequence was built on the first time
            anybody pressed Apply.
 
            A STEP THAT DECLARES NO SELECTION IS THE VARIABLE. That is not a
            guess: Invoke-HDTInstallApplicationsStep reads HDTApplications
            directly when the step names nothing, so a page reporting "nothing
            selected" would contradict the step it is editing.
 
            THE LIST IS THE SHARE'S OWN, through Get-HDTConsoleWorkspace - the
            command the browser already uses - so this window cannot offer an
            application the engine will not resolve, and importing one makes it
            appear here without anything being edited.
 
            AN APPLICATION THE SHARE DOES NOT HOLD IS SHOWN, TICKED, AND MARKED.
            A sequence naming one somebody has since deleted still opens and
            still says what it carries; a list that silently forgot it would
            rewrite the deployment the next time anybody pressed Apply.
 
            AN APPLICATION WHOSE DOCUMENT WILL NOT READ IS NOT OFFERED. Its id is
            the folder name and nothing else, so ticking it would write an id
            that may not be what app.yaml says.
 
            A SHARE THAT CANNOT BE READ IS NOT AN ERROR HERE. Offline, renamed or
            not yet created is exactly the moment somebody needs to look at the
            sequence; the page opens with an empty list and the document's own
            value.
 
        .PARAMETER Line
            The sequence document's lines.
 
        .PARAMETER Path
            Where those lines came from, so a parse failure can name the file.
 
        .PARAMETER Name
            The step to read.
 
        .PARAMETER Workspace
            The deployment share's root, which is where the catalog lives.
 
        .PARAMETER FileSystem
            An IFileSystem. Defaults to the real one.
 
        .PARAMETER Catalog
            The share's application rows, as Get-HDTConsoleWorkspace returns
            them. Handed in by the editor, which reads the share once when it
            opens: re-reading it on every keystroke froze the window. Omitted,
            this reads the share itself, which is what a script or a test wants.
 
        .PARAMETER Document
            What the host already parsed. The editor rebuilds its whole right
            pane after every edit, and a view model that re-parsed the same lines
            cost about 70ms of it. Omitted, this parses the lines itself.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with IsApplicationStep,
            Application, FromVariable, Variable, Written, Note, HasCatalog and
            the command format.
 
        .EXAMPLE
            (Get-HDTConsoleApplicationChoice -Line $line -Path $path -Name 'Install Applications' -Workspace C:\HDTLab\Share).Application |
                Format-Table Display, Selected, Missing
 
        .LINK
            Invoke-HDTInstallApplicationsStep
 
        .LINK
            Get-HDTConsoleDependencyChoice
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]] $Line,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter(Mandatory = $true, Position = 2)]
        [ValidateNotNullOrEmpty()]
        [string] $Name,

        [Parameter(Mandatory = $true, Position = 3)]
        [ValidateNotNullOrEmpty()]
        [string] $Workspace,

        [Parameter()]
        [AllowNull()]
        [object] $FileSystem,

        [Parameter()]
        [AllowNull()]
        [object[]] $Catalog,

        [Parameter()]
        [AllowNull()]
        [object] $Document
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if ($null -eq $FileSystem) { $FileSystem = New-HDTFileSystem }

    # THE VARIABLE MDT'S "install multiple applications" READS, and the one
    # Invoke-HDTInstallApplicationsStep falls back to. Named once here rather
    # than typed into three strings below.
    $fallbackVariable = 'HDTApplications'

    # HANDED IN? THEN NOTHING IS RE-PARSED. See the -Document help above.
    if ($null -ne $Document) {
        $sequence = $Document
    } else {
        $reader = New-HDTFileSystemFromText -Path $Path -Text ($Line -join [System.Environment]::NewLine)
        $sequence = Import-HDTSequenceDocument -Path $Path -FileSystem $reader
    }

    $step = @($sequence.Step | Where-Object { $_.Name -eq $Name })

    $isApplicationStep = (@($step).Count -gt 0 -and [string] $step[0].Type -eq 'InstallApplications')

    # -- what the document says -------------------------------------------

    $raw = $null

    if (@($step).Count -gt 0 -and $null -ne $step[0].Property -and $step[0].Property.Contains('selection')) {
        $raw = $step[0].Property['selection']
    }

    # BOTH FORMS ARE ONE LIST, which is what the step's own reader does: a YAML
    # list stays a list, and one string is a list written the other way.
    $chosen = @()
    $written = ''

    if ($raw -is [System.Collections.IList] -and -not ($raw -is [string])) {
        $chosen = @(@($raw) | ForEach-Object { ([string] $_).Trim() } |
                Where-Object { -not [string]::IsNullOrWhiteSpace($_) })

        $written = ($chosen -join ', ')
    } elseif ($null -ne $raw) {
        $written = ([string] $raw).Trim()

        $chosen = @(@($written -split '[,;]') | ForEach-Object { $_.Trim() } |
                Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    }

    # -- which of the two answers it is ------------------------------------

    $fromVariable = $false
    $variable = $fallbackVariable
    $note = ''

    if ([string]::IsNullOrWhiteSpace($written)) {
        # NO SELECTION IS THE VARIABLE, because that is what the step reads.
        $fromVariable = $true
        $note = 'This step names nothing, so it installs whatever %{0}% holds - what the wizard or a rule chose.' -f $fallbackVariable
    } elseif ($written -match '^\s*%([^%]+)%\s*$') {
        $fromVariable = $true
        $variable = [string] $Matches[1]
        $chosen = @()

        # WHAT IT WILL HOLD IS NOT KNOWN HERE, and guessing is worse than saying
        # so - the step logs the plan it resolves, once it has one.
        $note = 'This step installs whatever %{0}% holds - what the wizard or a rule chose, which cannot be read from this document.' -f $variable
    }

    # -- the share ---------------------------------------------------------

    $known = @()

    if ($null -ne $Catalog) {
        $known = @($Catalog)
    } else {
        try {
            $known = @((Get-HDTConsoleWorkspace -Path $Workspace -FileSystem $FileSystem).Application)
        } catch {
            # See the description: the editor opens on a share that will not read.
            $known = @()
        }
    }

    $readable = @(@($known) | Where-Object { $null -ne $_ -and [string] $_.Status -ne 'Error' })

    $offer = New-Object -TypeName System.Collections.ArrayList

    foreach ($current in @($readable)) {
        $id = [string] $current.Id

        [void] $offer.Add([pscustomobject] @{
                Id       = $id
                Display  = Get-HDTConsoleApplicationLabel -Name ([string] $current.Name) `
                    -Version ([string] $current.Version) -Id $id
                Selected = [bool] (@($chosen | Where-Object {
                            [string]::Equals($_, $id, [System.StringComparison]::OrdinalIgnoreCase) }).Count -gt 0)
                Missing  = $false
            })
    }

    # NAME ORDER, so the list does not reshuffle as the share grows.
    $row = New-Object -TypeName System.Collections.ArrayList
    foreach ($current in @(@($offer) | Sort-Object -Property Display)) { [void] $row.Add($current) }

    # AND THE ONES THE DOCUMENT NAMES THAT THE SHARE DOES NOT HAVE, at the end,
    # ticked. See the description for why they are not dropped.
    foreach ($one in @($chosen)) {
        $seen = @(@($row) | Where-Object {
                [string]::Equals([string] $_.Id, $one, [System.StringComparison]::OrdinalIgnoreCase) })

        if (@($seen).Count -gt 0) { continue }

        [void] $row.Add([pscustomobject] @{
                Id       = [string] $one
                Display  = ('{0} (not in this share)' -f $one)
                Selected = $true
                Missing  = $true
            })
    }

    return [pscustomobject] @{
        IsApplicationStep = $isApplicationStep
        Application       = [pscustomobject[]] @($row)

        FromVariable      = $fromVariable
        Variable          = [string] $variable
        Written           = [string] $written
        Note              = [string] $note

        # THE EMPTY SHARE SAYS SO. A list control with nothing in it reads as a
        # page that failed to load; an administrator who has imported no
        # applications needs to be told that is what happened.
        HasCatalog        = (@($readable).Count -gt 0)

        Command           = ("Set-HDTStepProperty -Line `$line -Name '{0}' -Property 'selection' -Value '<applications>'" -f $Name)
    }
}