Private/ConvertTo-DhSections.ps1
|
function ConvertTo-DhStatusName { <# .SYNOPSIS Normalise a status word or a cell-* class to a lower-case status name (v2.3.0). .DESCRIPTION 'cell-ok' -> 'ok', 'Critical' -> 'critical', $null -> ''. The browser maps the word to one of ok / warn / danger / info / nodata and shows anything it does not know as nodata, so this never throws: a typo is a grey chip, not a failed export. #> [OutputType([string])] param([AllowNull()] $Value) if ($null -eq $Value) { return '' } return ([string]$Value).Trim().ToLowerInvariant() -replace '^cell-', '' } function ConvertTo-DhSections { <# .SYNOPSIS Validate and normalise a list of detail-section specs (v2.3.0). .DESCRIPTION One section spec is shared by the detail drawer (Add-DhTable -DetailSections), Add-DhKeyValue, Add-DhRecordList and the findings split layout; the browser draws them all with the same renderSections() function. @{ Kind='kv'; Title='System'; Columns=2 Items=@(@{ Label='Model'; Value='VMware20,1'; Status='ok' }) } @{ Kind='bars'; Title='Resources'; Max=100; Unit='%' Items=@(@{ Label='CPU'; Value=2; Status='ok' }) } @{ Kind='statusList'; Items=@(@{ Label='NTDS'; Status='ok'; Text='Running' }) } @{ Kind='checks'; Items=@(@{ Label='Connectivity'; Ok=$true; Note='' }) } @{ Kind='table'; Columns=@('Drive','Free'); Rows=@(,@('C:','73 GB')); MaxRows=50 } @{ Kind='text'; Text='Free-form paragraph.' } @{ Kind='links'; Items=@(@{ Label='MS Learn'; Url='https://learn.microsoft.com/' }) } Returns an array of ordered hashtables with lower-case Kind. Throws on a malformed spec, naming the context. Status values are normalised, not checked. #> [CmdletBinding()] [OutputType([object[]])] param( [AllowNull()] [object[]] $Sections, [Parameter(Mandatory)] [string] $Context ) $kinds = 'kv', 'bars', 'statuslist', 'checks', 'table', 'text', 'links' $n = 0 $out = foreach ($s in @($Sections)) { if ($null -eq $s) { continue } $n++ if ($s -isnot [System.Collections.IDictionary]) { throw "${Context}: section #$n must be a hashtable. Got: $($s.GetType().Name)" } $kind = if ($s.Contains('Kind')) { ([string]$s['Kind']).Trim().ToLowerInvariant() } else { 'text' } if ($kind -notin $kinds) { throw "${Context}: section #$n has Kind '$($s['Kind'])'. Valid: kv, bars, statusList, checks, table, text, links." } $sc = "$Context section #$n ($kind)" $title = if ($s.Contains('Title')) { [string]$s['Title'] } else { '' } $items = @(if ($s.Contains('Items') -and $null -ne $s['Items']) { @($s['Items']) | Where-Object { $null -ne $_ } }) $sec = [ordered]@{ Kind = $kind; Title = $title } switch ($kind) { 'kv' { $cols = 2 if ($s.Contains('Columns') -and $null -ne $s['Columns']) { if (-not [int]::TryParse([string]$s['Columns'], [ref]$cols) -or $cols -lt 1 -or $cols -gt 6) { throw "${sc}: Columns must be a whole number from 1 to 6. Got: $($s['Columns'])" } } $sec['Columns'] = $cols $sec['Items'] = @(foreach ($it in $items) { if ($it -isnot [System.Collections.IDictionary] -or -not $it.Contains('Label')) { throw "${sc}: each item must be @{ Label; Value; Status }." } $st = if ($it.Contains('Status')) { $it['Status'] } elseif ($it.Contains('Class')) { $it['Class'] } else { $null } [ordered]@{ Label = [string]$it['Label'] Value = if ($it.Contains('Value') -and $null -ne $it['Value']) { [string]$it['Value'] } else { '' } Status = ConvertTo-DhStatusName $st } }) } 'bars' { $max = 0.0 if ($s.Contains('Max') -and $null -ne $s['Max']) { if (-not [double]::TryParse([string]$s['Max'], [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$max) -or $max -lt 0) { throw "${sc}: Max must be a number >= 0. Got: $($s['Max'])" } } $sec['Max'] = $max $sec['Unit'] = if ($s.Contains('Unit')) { [string]$s['Unit'] } else { '' } $sec['Items'] = @(foreach ($it in $items) { if ($it -isnot [System.Collections.IDictionary] -or -not $it.Contains('Label')) { throw "${sc}: each item must be @{ Label; Value; Status; Display }." } $v = $null if ($it.Contains('Value') -and $null -ne $it['Value'] -and [string]$it['Value'] -ne '') { $d = 0.0 if (-not [double]::TryParse([string]$it['Value'], [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$d)) { throw "${sc}: item '$($it['Label'])' has a non-numeric Value '$($it['Value'])'." } $v = $d } $st = if ($it.Contains('Status')) { $it['Status'] } elseif ($it.Contains('Class')) { $it['Class'] } else { $null } [ordered]@{ Label = [string]$it['Label'] Value = $v Display = if ($it.Contains('Display') -and $null -ne $it['Display']) { [string]$it['Display'] } else { $null } Status = ConvertTo-DhStatusName $st } }) } 'statuslist' { $sec['Items'] = @(foreach ($it in $items) { if ($it -isnot [System.Collections.IDictionary] -or -not $it.Contains('Label')) { throw "${sc}: each item must be @{ Label; Status; Text }." } [ordered]@{ Label = [string]$it['Label'] Status = ConvertTo-DhStatusName $(if ($it.Contains('Status')) { $it['Status'] } else { $null }) Text = if ($it.Contains('Text') -and $null -ne $it['Text']) { [string]$it['Text'] } else { $null } } }) } 'checks' { $sec['Items'] = @(foreach ($it in $items) { if ($it -isnot [System.Collections.IDictionary] -or -not $it.Contains('Label')) { throw "${sc}: each item must be @{ Label; Ok; Status; Note }." } $ok = $null if ($it.Contains('Ok') -and $null -ne $it['Ok']) { $ok = [bool]$it['Ok'] } [ordered]@{ Label = [string]$it['Label'] Ok = $ok Status = ConvertTo-DhStatusName $(if ($it.Contains('Status')) { $it['Status'] } else { $null }) Note = if ($it.Contains('Note') -and $null -ne $it['Note']) { [string]$it['Note'] } else { '' } } }) } 'table' { if (-not $s.Contains('Columns') -or @($s['Columns']).Count -eq 0) { throw "${sc}: a table section needs Columns." } $sec['Columns'] = @(@($s['Columns']) | ForEach-Object { [string]$_ }) $mr = 50 if ($s.Contains('MaxRows') -and $null -ne $s['MaxRows']) { if (-not [int]::TryParse([string]$s['MaxRows'], [ref]$mr) -or $mr -lt 1) { throw "${sc}: MaxRows must be a whole number >= 1. Got: $($s['MaxRows'])" } } $sec['MaxRows'] = $mr $rowList = [System.Collections.Generic.List[object]]::new() foreach ($r in @($s['Rows'])) { if ($null -eq $r) { continue } $cells = foreach ($c in @($r)) { if ($c -is [System.Collections.IDictionary]) { [ordered]@{ Text = [string]$c['Text']; Status = ConvertTo-DhStatusName $c['Status'] } } else { if ($null -eq $c) { '' } else { [string]$c } } } $rowList.Add(@($cells)) } $sec['Rows'] = $rowList.ToArray() } 'links' { $sec['Items'] = @(foreach ($it in $items) { if ($it -isnot [System.Collections.IDictionary]) { throw "${sc}: each item must be @{ Label; Url }." } $url = Test-DhSafeActionUrl -Url ([string]$it['Url']) -Context "$sc link" if (-not $url) { throw "${sc}: a link has no Url." } [ordered]@{ Label = if ($it['Label']) { [string]$it['Label'] } else { $url }; Url = $url } }) } 'text' { $sec['Text'] = if ($s.Contains('Text')) { [string]$s['Text'] } else { '' } } } $sec } # The comma keeps a single section an array of one: without it the pipeline # unrolls it to the bare ordered dictionary, and .Count / [0] then mean keys. return , @($out) } function ConvertTo-DhDetailSpec { <# .SYNOPSIS Normalise a "Detail" value to @{ Title; Subtitle; Sections } or $null (v2.3.0). .DESCRIPTION Accepted shapes, for a status-grid cell, a finding or a record: 'multi-line text' one text section @{ Kind='kv'; Items=... } one section @( @{Kind=..}, @{Kind=..} ) several sections @{ Title='..'; Subtitle='..'; Sections=@(...) } the full form Empty input returns $null, so "no detail" costs nothing in the JSON. #> [CmdletBinding()] param([AllowNull()] $Detail, [Parameter(Mandatory)] [string] $Context) if ($null -eq $Detail) { return $null } $title = ''; $sub = ''; $secs = $null if ($Detail -is [string]) { if ([string]::IsNullOrWhiteSpace($Detail)) { return $null } $secs = @(@{ Kind = 'text'; Text = $Detail }) } elseif ($Detail -is [System.Collections.IDictionary]) { if ($Detail.Contains('Sections')) { $title = if ($Detail.Contains('Title')) { [string]$Detail['Title'] } else { '' } $sub = if ($Detail.Contains('Subtitle')) { [string]$Detail['Subtitle'] } else { '' } $secs = @($Detail['Sections']) } elseif ($Detail.Contains('Kind')) { $secs = @($Detail) } else { throw "${Context}: Detail hashtable needs Sections (or a Kind for a single section)." } } elseif ($Detail -is [System.Collections.IEnumerable]) { $secs = @($Detail) } else { throw "${Context}: Detail must be text, a section hashtable, or an array of sections. Got: $($Detail.GetType().Name)" } $norm = ConvertTo-DhSections -Sections $secs -Context "$Context Detail" if ($norm.Count -eq 0 -and -not $title) { return $null } return [ordered]@{ Title = $title; Subtitle = $sub; Sections = $norm } } |