Modules/ScubaConfigApp/ScubaConfigAppHelpers/ScubaConfigAppResultsHelper.psm1
|
Function Initialize-ResultsTab { <# .SYNOPSIS Initializes the Results tab and scans for existing ScubaGear reports. .DESCRIPTION This function sets up the Results tab, scans the output directory for existing reports, and creates tabs for each found report with formatted timestamps. #> # Enable the Results tab $syncHash.ResultsTab.IsEnabled = $true $syncHash.ResultsTab.Header = $syncHash.UIConfigs.Reports.tabName # Add event handlers for control buttons $syncHash.ResultsRefresh_Button.Add_Click({ Update-ResultsTab }) $syncHash.ResultsOpenFolder_Button.Add_Click({ Open-ResultsFolder }) $syncHash.ResultsDefaultScanPath_Button.Add_Click({ Set-DefaultResultsScanPath }) $syncHash.ResultsBrowseScanPath_Button.Add_Click({ $folderDialog = New-Object System.Windows.Forms.FolderBrowserDialog $folderDialog.Description = "Select the folder to scan for existing ScubaGear results" $folderDialog.ShowNewFolderButton = $true $folderDialog.RootFolder = [System.Environment+SpecialFolder]::MyComputer $currentPath = $syncHash.ResultsScanPath_TextBox.Text $defaultPath = Get-ResolvedOutputPath if (-not [string]::IsNullOrWhiteSpace($currentPath) -and $currentPath.Trim() -ne "." -and (Test-Path $currentPath) -and $currentPath.Trim() -ne $defaultPath.Trim()) { $folderDialog.SelectedPath = $currentPath } $result = $folderDialog.ShowDialog() if ($result -eq [System.Windows.Forms.DialogResult]::OK) { $syncHash.ResultsScanPath_TextBox.Text = $folderDialog.SelectedPath Update-ResultsTab } }) # Initialize the scan path to the current output path. Set-DefaultResultsScanPath -NoRefresh # Initial scan for existing results Update-ResultsTab } Function Get-ResolvedOutputPath { <# .SYNOPSIS Resolves the configured ScubaGear output path to the same runtime value used for generating reports. #> $configuredPath = $syncHash.AdvancedSettingsData["OutPath"] if ([string]::IsNullOrEmpty($configuredPath)) { $configuredPath = $syncHash.OutPath_TextBox.Text } if ([string]::IsNullOrEmpty($configuredPath)) { $configuredPath = $syncHash.ScubaDefaults.OutPath } if ([string]::IsNullOrEmpty($configuredPath) -or $configuredPath.Trim() -eq ".") { $configuredPath = [Environment]::GetFolderPath('MyDocuments') } return $configuredPath } Function Set-DefaultResultsScanPath { <# .SYNOPSIS Resets the Results scan path back to the current output path without changing the actual report output settings. #> param( [switch]$NoRefresh ) $outputPath = Get-ResolvedOutputPath $syncHash.ResultsScanPath_TextBox.Text = $outputPath if (-not $NoRefresh) { Update-ResultsTab } } Function Update-ResultsTab { <# .SYNOPSIS Scans for ScubaGear result folders and creates/updates result tabs using simple background jobs. .DESCRIPTION This function searches the output directory for M365BaselineConformance folders, parses timestamps, and creates tabs with properly formatted dates. Uses PowerShell jobs for background processing to prevent UI blocking. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")] [CmdletBinding()] param() # Prevent multiple concurrent scans if ($syncHash.ResultsScanInProgress) { Write-DebugOutput -Message "Results scan already in progress, skipping" -Source $MyInvocation.MyCommand -Level "Info" return } $syncHash.ResultsScanInProgress = $true # Show progress indicator immediately $syncHash.ResultsScanProgress.Visibility = "Visible" $syncHash.ResultsScanStatus.Text = "Scanning for ScubaGear reports..." $syncHash.ResultsProgressBar.IsIndeterminate = $true # Clear existing result tabs (keep ResultsEmptyTab) $tabsToRemove = @() foreach ($tab in $syncHash.ResultsTabControl.Items) { if ($tab.Name -ne "ResultsEmptyTab") { $tabsToRemove += $tab } } foreach ($tab in $tabsToRemove) { $syncHash.ResultsTabControl.Items.Remove($tab) } # Show loading state $syncHash.ResultsEmptyTab.Visibility = "Visible" $syncHash.ResultsTabControl.SelectedItem = $syncHash.ResultsEmptyTab Update-ResultsCount 0 # Update empty tab content to show loading message $loadingContent = @" <ScrollViewer xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"> <StackPanel Margin="20" HorizontalAlignment="Center" VerticalAlignment="Center"> <TextBlock Text="Loading: Scanning for ScubaGear reports..." FontSize="16" TextAlignment="Center" Foreground="{DynamicResource MutedTextBrush}" Margin="10"/> <TextBlock Text="Please wait while we search for existing reports..." FontSize="12" TextAlignment="Center" Foreground="{DynamicResource MutedTextBrush}" Margin="10"/> </StackPanel> </ScrollViewer> "@ try { $loadingControl = [Windows.Markup.XamlReader]::Parse($loadingContent) $syncHash.ResultsEmptyTab.Content = $loadingControl } catch { Write-DebugOutput -Message "Error creating loading content: $($_.Exception.Message)" -Source $MyInvocation.MyCommand -Level "Warning" } # Get configuration values for the background job. # Resolution order: AdvancedSettingsData (user-configured) → TextBox.Text (UI default) # → ScubaDefaults (ScubaConfigDefaults.json) → UIConfigs.defaultAdvancedSettings (app JSON) $folderName = $syncHash.AdvancedSettingsData["OutFolderName"] if (-not $folderName) { $folderName = $syncHash.OutFolderName_TextBox.Text } if (-not $folderName) { $folderName = $syncHash.ScubaDefaults.OutFolderName } if (-not $folderName) { $folderName = $syncHash.UIConfigs.defaultAdvancedSettings.OutFolderName_TextBox } $jsonfilename = $syncHash.AdvancedSettingsData["OutJsonFileName"] if (-not $jsonfilename) { $jsonfilename = $syncHash.OutJsonFileName_TextBox.Text } if (-not $jsonfilename) { $jsonfilename = $syncHash.ScubaDefaults.OutJsonFileName } if (-not $jsonfilename) { $jsonfilename = $syncHash.UIConfigs.defaultAdvancedSettings.OutJsonFileName_TextBox } $configuredPath = Get-ResolvedOutputPath $scanPathText = $syncHash.ResultsScanPath_TextBox.Text $scanPath = $null if (-not [string]::IsNullOrWhiteSpace($scanPathText)) { $scanPath = $scanPathText.Trim() if ($scanPath -eq ".") { $scanPath = [Environment]::GetFolderPath('MyDocuments') } } if ([string]::IsNullOrWhiteSpace($scanPath)) { $scanPath = $configuredPath } Write-DebugOutput -Message "Results scan: folder='$folderName', json='$jsonfilename', outPath='$configuredPath', scanPath='$scanPath'" -Source $MyInvocation.MyCommand -Level "Info" # Build a deduplicated list of paths to search: # 1. The explicit Results scan path override (if set) # 2. The configured/resolved OutPath (where ScubaGear wrote the last run) # 3. Shell-resolved Documents (handles OneDrive redirection automatically) # 4. Any additional OneDrive\Documents variants found under USERPROFILE # 5. $env:USERPROFILE home dir (PSGallery default CWD when run standalone) $searchPaths = [System.Collections.Generic.List[string]]::new() if (![string]::IsNullOrEmpty($scanPath) -and (Test-Path $scanPath)) { $searchPaths.Add($scanPath) } if (![string]::IsNullOrEmpty($configuredPath) -and (Test-Path $configuredPath) -and !$searchPaths.Contains($configuredPath)) { $searchPaths.Add($configuredPath) } $fallbacks = [System.Collections.Generic.List[string]]::new() # Shell-resolved Documents - always correct even with OneDrive redirection $fallbacks.Add([Environment]::GetFolderPath('MyDocuments')) # Enumerate every OneDrive-variant Documents folder (personal, corporate, named accounts) Get-Item (Join-Path $env:USERPROFILE 'OneDrive*') -ErrorAction SilentlyContinue | ForEach-Object { $fallbacks.Add((Join-Path $_.FullName 'Documents')) } # Plain local Documents and home dir as final catch-all $fallbacks.Add((Join-Path $env:USERPROFILE 'Documents')) $fallbacks.Add($env:USERPROFILE) foreach ($fb in $fallbacks) { if (![string]::IsNullOrEmpty($fb) -and (Test-Path $fb) -and !$searchPaths.Contains($fb)) { $searchPaths.Add($fb) } } # Convert to plain string[] - List[string] doesn't survive Start-Job serialization reliably $searchPathsArray = [string[]]$searchPaths Write-DebugOutput -Message "Results search paths ($($searchPathsArray.Count)): $($searchPathsArray -join '; ')" -Source $MyInvocation.MyCommand -Level "Info" $maximumResults = $syncHash.UIConfigs.MaximumResults # Join paths with | delimiter - [string[]] arrays don't survive Start-Job CLIXML serialization $searchPathsPiped = $searchPathsArray -join '|' # Start background job with simple script block $job = Start-Job -ScriptBlock { param([string]$SearchPathsPiped, [string]$FolderName, [string]$JsonFileName, [int]$MaxResults) # Split the delimiter-joined string back into an array $SearchPaths = $SearchPathsPiped -split '\|' | Where-Object { $_ -ne '' } # Diagnostic log accumulates inside the job and is returned alongside results $diagLog = [System.Collections.Generic.List[string]]::new() $diagLog.Add("Job started. FolderName='$FolderName' JsonFileName='$JsonFileName' MaxResults=$MaxResults SearchPaths=$($SearchPaths.Count)") function Get-ResultsReportTimeStamp { param([string]$Name, [string]$SearchPrefix) if ($Name -match "${SearchPrefix}_(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})") { $year = $matches[1]; $month = $matches[2]; $day = $matches[3] $hour = $matches[4]; $minute = $matches[5]; $second = $matches[6] $timestamp = Get-Date -Year $year -Month $month -Day $day -Hour $hour -Minute $minute -Second $second $tabHeader = "$year-$month-$day ($hour`:$minute`:$second)" $now = Get-Date $timespan = $now - $timestamp if ($timespan.TotalDays -lt 1) { if ($timespan.TotalHours -lt 1) { if ($timespan.TotalMinutes -lt 1) { $relativeTime = "Just now" } else { $relativeTime = "$([math]::Floor($timespan.TotalMinutes)) minutes ago" } } else { $relativeTime = "$([math]::Floor($timespan.TotalHours)) hours ago" } } elseif ($timespan.TotalDays -lt 7) { $relativeTime = "$([math]::Floor($timespan.TotalDays)) days ago" } else { $relativeTime = $timestamp.ToString("MMM dd, yyyy") } return [PSCustomObject]@{ TabHeader = $tabHeader TimeStamp = $timestamp RelativeTime = $relativeTime } } else { return [PSCustomObject]@{ TabHeader = $Name TimeStamp = "Unknown time" RelativeTime = "Unknown time" } } } $resultsData = @() foreach ($searchPath in $SearchPaths) { $diagLog.Add("Checking path: '$searchPath' (exists=$(Test-Path $searchPath))") if (Test-Path $searchPath) { $filter = "${FolderName}_*" $diagLog.Add(" Searching with filter: '$filter'") $allDirs = Get-ChildItem -Path $searchPath -Directory -Filter $filter -ErrorAction SilentlyContinue $diagLog.Add(" Raw match count: $($allDirs.Count)") if ($allDirs.Count -gt 0) { $diagLog.Add(" Matched folders: $($allDirs.Name -join ', ')") } $foldersInPath = $allDirs | Sort-Object -Property LastWriteTime -Descending | Select-Object -First $MaxResults | ForEach-Object { $jsonFile = Get-ChildItem -Path $_.FullName -Filter "${JsonFileName}*.json" -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName $diagLog.Add(" Folder '$($_.Name)': json='$jsonFile'") $timeNameInfo = Get-ResultsReportTimeStamp -SearchPrefix $FolderName -Name $_.BaseName [PSCustomObject]@{ ReportName = $_.BaseName TabHeader = $timeNameInfo.TabHeader ReportTimeStamp = $timeNameInfo.TimeStamp RelativeTime = $timeNameInfo.RelativeTime ReportPath = $_.FullName JsonResultsPath = $jsonFile } } $resultsData += $foldersInPath } } $diagLog.Add("Job complete. Total results: $($resultsData.Count)") return [PSCustomObject]@{ Results = $resultsData; DiagLog = $diagLog } } -ArgumentList $searchPathsPiped, $folderName, $jsonfilename, $maximumResults # Use a timer to poll job status instead of events $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromMilliseconds(500) # Check every 500ms $timer.Add_Tick({ if ($job.State -eq 'Completed' -or $job.State -eq 'Failed' -or $job.State -eq 'Stopped') { $timer.Stop() try { if ($job.State -eq 'Completed') { $jobOutput = Receive-Job -Job $job -ErrorAction Stop # Job now returns a PSCustomObject with Results + DiagLog if ($jobOutput -is [PSCustomObject] -and $jobOutput.PSObject.Properties['DiagLog']) { foreach ($line in $jobOutput.DiagLog) { Write-DebugOutput -Message "[Job] $line" -Source "Update-ResultsTab" -Level "Info" } $resultsData = @($jobOutput.Results) } else { # Fallback for legacy format $resultsData = @($jobOutput) } Write-DebugOutput -Message "Job completed. Results count: $($resultsData.Count)" -Source "Update-ResultsTab" -Level "Info" # Hide progress indicator $syncHash.ResultsScanProgress.Visibility = "Collapsed" $syncHash.ResultsScanInProgress = $false if ($resultsData -and $resultsData.Count -gt 0) { # Store results $syncHash.ResultsJsonData = $resultsData # Hide empty tab and create result tabs $syncHash.ResultsEmptyTab.Visibility = "Collapsed" # Create tabs for each result folder foreach ($Report in $syncHash.ResultsJsonData) { New-ResultsReportTab -Report $Report } # Select the most recent tab (first in sorted list) if ($syncHash.ResultsTabControl.Items.Count -gt 1) { $syncHash.ResultsTabControl.SelectedIndex = 1 # Skip ResultsEmptyTab at index 0 } Update-ResultsCount $syncHash.ResultsJsonData.Count Write-DebugOutput -Message "Successfully loaded $($syncHash.ResultsJsonData.Count) results" -Source "Update-ResultsTab" -Level "Info" } else { # Show empty results $syncHash.ResultsEmptyTab.Visibility = "Visible" $syncHash.ResultsTabControl.SelectedItem = $syncHash.ResultsEmptyTab Update-ResultsCount 0 # Reset empty tab content to default "no results" message $emptyContent = @" <ScrollViewer xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"> <StackPanel Margin="20" HorizontalAlignment="Center" VerticalAlignment="Center"> <TextBlock Text="No ScubaGear reports found" FontSize="16" TextAlignment="Center" Foreground="{DynamicResource MutedTextBrush}" Margin="10"/> <TextBlock Text="Run ScubaGear to generate reports that will appear here." FontSize="12" TextAlignment="Center" Foreground="{DynamicResource MutedTextBrush}" Margin="10"/> </StackPanel> </ScrollViewer> "@ try { $emptyControl = [Windows.Markup.XamlReader]::Parse($emptyContent) $syncHash.ResultsEmptyTab.Content = $emptyControl } catch { Write-DebugOutput -Message "Error creating empty content: $($_.Exception.Message)" -Source "Update-ResultsTab" -Level "Warning" } Write-DebugOutput -Message "No results found in search paths" -Source "Update-ResultsTab" -Level "Info" } } else { # Job failed or stopped $jobErrors = Receive-Job -Job $job -ErrorAction SilentlyContinue $errorMessage = if ($jobErrors) { $jobErrors -join "; " } else { "Job failed with state: $($job.State)" } Write-DebugOutput -Message "Job failed with state: $($job.State). Error: $errorMessage" -Source "Update-ResultsTab" -Level "Error" # Hide progress indicator $syncHash.ResultsScanProgress.Visibility = "Collapsed" $syncHash.ResultsScanInProgress = $false # Show error state $syncHash.ResultsEmptyTab.Visibility = "Visible" $syncHash.ResultsTabControl.SelectedItem = $syncHash.ResultsEmptyTab Update-ResultsCount 0 $errorContent = @" <ScrollViewer xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"> <StackPanel Margin="20" HorizontalAlignment="Center" VerticalAlignment="Center"> <TextBlock Text="Error scanning for reports" FontSize="16" TextAlignment="Center" Foreground="Red" Margin="10"/> <TextBlock Text="$errorMessage" FontSize="12" TextAlignment="Center" Foreground="{DynamicResource MutedTextBrush}" TextWrapping="Wrap" MaxWidth="400" Margin="10"/> </StackPanel> </ScrollViewer> "@ try { $errorControl = [Windows.Markup.XamlReader]::Parse($errorContent) $syncHash.ResultsEmptyTab.Content = $errorControl } catch { Write-DebugOutput -Message "Error creating error content: $($_.Exception.Message)" -Source "Update-ResultsTab" -Level "Warning" } } } catch { Write-DebugOutput -Message "Error processing job results: $($_.Exception.Message)" -Source "Update-ResultsTab" -Level "Error" # Hide progress indicator $syncHash.ResultsScanProgress.Visibility = "Collapsed" $syncHash.ResultsScanInProgress = $false # Show error state $syncHash.ResultsEmptyTab.Visibility = "Visible" $syncHash.ResultsTabControl.SelectedItem = $syncHash.ResultsEmptyTab Update-ResultsCount 0 } finally { # Clean up the job Remove-Job -Job $job -Force -ErrorAction SilentlyContinue } } else { # Job still running - update status if needed $syncHash.ResultsScanStatus.Text = "Scanning for ScubaGear reports..." } }.GetNewClosure()) # Start the timer $timer.Start() Write-DebugOutput -Message "Background job started for results scanning (Job ID: $($job.Id))" -Source $MyInvocation.MyCommand -Level "Info" } Function New-ResultsReportTab { <# .SYNOPSIS Creates a native WPF tab displaying ScubaGear results from ScubaResults JSON data. #> param( [Parameter(Mandatory)] [PSCustomObject]$Report ) # Create new TabItem $newTab = New-Object System.Windows.Controls.TabItem # Create folder-style header using text prefix $newTab.Header = "Report: $($Report.TabHeader)" $newTab.Name = "Result_$($Report.ReportName -Replace '\W+', '_')" # Load content immediately instead of lazy loading try { $jsonResultsPath = [string]$Report.JsonResultsPath if (-not [string]::IsNullOrWhiteSpace($jsonResultsPath) -and (Test-Path -LiteralPath $jsonResultsPath)) { $jsonContent = Get-Content -LiteralPath $jsonResultsPath -Raw $scubaData = $jsonContent | ConvertFrom-Json # Validate the data $isValidData = Test-ResultsDataValidity -ScubaData $scubaData -ReportPath $Report.ReportPath if ($isValidData) { $relativeTimeString = $Report.RelativeTime + " (" + $Report.ReportTimeStamp + ")" $reportContent = New-ResultsContent -ScubaData $scubaData -ReportPath $Report.ReportPath -RelativeTime $relativeTimeString $newTab.Content = $reportContent } else { $errorTab = New-ResultsNoDataTab -ReportPath $Report.ReportPath -Message "Report data appears to be corrupted or incomplete.`n`nThe ScubaResults JSON file contains invalid summary data.`n`nThis may indicate the ScubaGear scan was interrupted or encountered errors." $newTab.Content = $errorTab } } else { $errorTab = New-ResultsNoDataTab -ReportPath $Report.ReportPath -Message "JSON file not found or unavailable: $jsonResultsPath" $newTab.Content = $errorTab } } catch { Write-DebugOutput -Message "Error loading report content: $($_.Exception.Message)" -Source $MyInvocation.MyCommand -Level "Error" $errorTab = New-ResultsNoDataTab -ReportPath $Report.ReportPath -Message "Error loading report content: $($_.Exception.Message)" $newTab.Content = $errorTab } # Add to main tab control $syncHash.ResultsTabControl.Items.Add($newTab) Write-DebugOutput -Message "Added report tab with immediate content loading: $($Report.TabHeader)" -Source $MyInvocation.MyCommand -Level "Info" } Function Test-ResultsDataValidity { <# .SYNOPSIS Validates ScubaGear data to ensure it's not corrupted or malformed. .DESCRIPTION Checks for common issues like invalid Summary data where all values are "9" or other placeholder values. #> param( [Parameter(Mandatory)] [PSCustomObject]$ScubaData, [string]$ReportPath ) try { Write-DebugOutput -Message "Validating ScubaData for report: $ReportPath" -Source $MyInvocation.MyCommand -Level "Debug" Write-DebugOutput -Message "ScubaData type: $($ScubaData.GetType().FullName)" -Source $MyInvocation.MyCommand -Level "Debug" # Check if Summary exists if (-not $ScubaData.Summary) { Write-DebugOutput -Message "No Summary section found in ScubaData" -Source $MyInvocation.MyCommand -Level "Error" return $false } # Check if Results exists if (-not $ScubaData.Results) { Write-DebugOutput -Message "No Results section found in ScubaData" -Source $MyInvocation.MyCommand -Level "Error" return $false } # Check for invalid Summary data (common issue: all values are "9") $summaryProducts = $ScubaData.Summary | Get-Member -MemberType NoteProperty if ($summaryProducts -and $summaryProducts.Count -gt 0) { $invalidDataCount = 0 $totalProductsChecked = 0 foreach ($productName in $summaryProducts.Name) { $productSummary = $ScubaData.Summary.$productName $totalProductsChecked++ # Check if all summary values are the same invalid placeholder (like "9") $passes = [string]$productSummary.Passes $warnings = [string]$productSummary.Warnings $failures = [string]$productSummary.Failures $manual = [string]$productSummary.Manual $errors = [string]$productSummary.Errors # Flag as invalid if all values are the same non-zero number (common corruption pattern) if ($passes -eq $warnings -and $warnings -eq $failures -and $failures -eq $manual -and $manual -eq $errors -and $passes -ne "0") { Write-DebugOutput -Message "Invalid summary data detected for product $productName`: all values are '$passes' (likely corrupted scan result)" -Source $MyInvocation.MyCommand -Level "Error" $invalidDataCount++ } # Special check for the common "9" corruption pattern if ($passes -eq "9" -and $warnings -eq "9" -and $failures -eq "9" -and $manual -eq "9" -and $errors -eq "9") { Write-DebugOutput -Message "Detected '9' corruption pattern for product $productName - this indicates an incomplete or failed ScubaGear scan" -Source $MyInvocation.MyCommand -Level "Error" $invalidDataCount++ } # Also check for obviously invalid values (non-numeric or negative) $allValues = @($passes, $warnings, $failures, $manual, $errors) foreach ($value in $allValues) { if (-not [int]::TryParse($value, [ref]$null) -or [int]$value -lt 0) { Write-DebugOutput -Message "Invalid summary value detected for product $productName`: '$value' is not a valid non-negative integer" -Source $MyInvocation.MyCommand -Level "Error" $invalidDataCount++ break } } } # If more than half the products have invalid data, consider the whole report invalid if ($invalidDataCount -gt ($totalProductsChecked / 2)) { Write-DebugOutput -Message "Report validation failed: $invalidDataCount of $totalProductsChecked products have invalid summary data" -Source $MyInvocation.MyCommand -Level "Error" return $false } } else { Write-DebugOutput -Message "No products found in Summary section" -Source $MyInvocation.MyCommand -Level "Error" return $false } # Check if Results has valid structure $resultProducts = $ScubaData.Results | Get-Member -MemberType NoteProperty if (-not $resultProducts -or $resultProducts.Count -eq 0) { Write-DebugOutput -Message "No products found in Results section" -Source $MyInvocation.MyCommand -Level "Error" return $false } # Check if Results actually contains meaningful data (not just empty arrays) $hasValidResults = $false foreach ($productName in $resultProducts.Name) { $productResults = $ScubaData.Results.$productName if ($productResults -and $productResults.Count -gt 0) { # Check if any result has meaningful data (Groups with Controls) foreach ($group in $productResults) { if ($group.Controls -and $group.Controls.Count -gt 0) { $hasValidResults = $true break } } if ($hasValidResults) { break } } } if (-not $hasValidResults) { Write-DebugOutput -Message "Results section exists but contains no meaningful compliance data" -Source $MyInvocation.MyCommand -Level "Error" return $false } # Basic validation passed Write-DebugOutput -Message "ScubaData validation passed for report: $ReportPath" -Source $MyInvocation.MyCommand -Level "Debug" return $true } catch { Write-DebugOutput -Message "Error during ScubaData validation: $($_.Exception.Message)" -Source $MyInvocation.MyCommand -Level "Error" return $false } } Function New-ResultsContent { <# .SYNOPSIS Creates the content for a results tab using the provided ScubaData. Optimized for faster loading with simplified product tabs. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "ReportPath")] param( [PSCustomObject[]]$ScubaData, [string]$ReportPath, [string]$RelativeTime ) Write-DebugOutput -Message "New-ResultsContent called for report: $ReportPath" -Source $MyInvocation.MyCommand -Level "Debug" # Safety check - validate data before processing if (-not $ScubaData) { return New-ResultsNoDataTab -ReportPath $ReportPath -Message "ScubaData is null - returning error tab" } if (-not $ScubaData.Summary -or -not $ScubaData.Results) { return New-ResultsNoDataTab -ReportPath $ReportPath -Message "ScubaData missing Summary or Results sections - returning error tab" } # Create simplified XAML template (without heavy product tabs initially) $xamlTemplate = @" <ScrollViewer xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"> <StackPanel Margin="8"> <!-- Tenant Header Card --> <Border Style="{DynamicResource Card}" Margin="0,0,0,8"> <Grid Margin="12"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <!-- Left side - Tenant info --> <StackPanel Grid.Column="0"> <TextBlock Text="ScubaGear Assessment Report" FontSize="18" FontWeight="Bold" Margin="0,0,0,6"/> <!-- Tenant Info Table --> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="160"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <TextBlock Grid.Row="0" Grid.Column="0" Text="Tenant Display Name" FontWeight="SemiBold" Margin="0,2"/> <TextBlock Grid.Row="0" Grid.Column="1" Name="TenantDisplayName" Text="{DISPLAY_NAME}" Margin="6,2,0,2"/> <TextBlock Grid.Row="1" Grid.Column="0" Text="Tenant Domain Name" FontWeight="SemiBold" Margin="0,2"/> <TextBlock Grid.Row="1" Grid.Column="1" Name="TenantDomainName" Text="{DOMAIN_NAME}" Margin="6,2,0,2"/> <TextBlock Grid.Row="2" Grid.Column="0" Text="Tenant ID" FontWeight="SemiBold" Margin="0,2"/> <TextBlock Grid.Row="2" Grid.Column="1" Name="TenantId" Text="{TENANT_ID}" Margin="6,2,0,2"/> <TextBlock Grid.Row="3" Grid.Column="0" Text="Report Date" FontWeight="SemiBold" Margin="0,2"/> <TextBlock Grid.Row="3" Grid.Column="1" Name="ReportDate" Text="{REPORT_DATE}" Margin="6,2,0,2"/> <TextBlock Grid.Row="4" Grid.Column="0" Text="Scuba Version" FontWeight="SemiBold" Margin="0,2"/> <TextBlock Grid.Row="4" Grid.Column="1" Name="ScubaVersion" Text="{SCUBA_VERSION}" Margin="6,2,0,2"/> </Grid> </StackPanel> <!-- Right side - Action buttons --> <StackPanel Grid.Column="1" HorizontalAlignment="Right" VerticalAlignment="Top"> <Button Name="OpenHtmlBtn" Style="{DynamicResource PrimaryButton}" Content="Open Full HTML Report" Margin="0,0,0,6" Width="190" Height="32"/> <Button Name="OpenFolderBtn" Style="{DynamicResource SecondaryButton}" Content="Open Report Folder" Width="190" Margin="0,0,0,8" Height="32"/> <Button Name="OpenYamlBtn" Style="{DynamicResource SecondaryButton}" Content="View Configuration" Width="190" Margin="0,0,0,8" Height="32" Visibility="Collapsed"/> <StackPanel VerticalAlignment="bottom"> <TextBlock Text="Report UUID:" FontSize="10" Foreground="LightGray"/> <TextBlock Text="{REPORT_UUID}" FontSize="10" FontFamily="Consolas" Margin="0,0,0,0" Foreground="Gray"/> </StackPanel> </StackPanel> </Grid> </Border> <!-- Summary Section Only (Fast Loading) --> <Border Style="{DynamicResource Card}" Margin="0,0,0,8"> <StackPanel Margin="12"> <TextBlock Text="Baseline Conformance Summary" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8"/> <ItemsControl Name="SummaryItemsControl"> <ItemsControl.ItemTemplate> <DataTemplate> <Border Style="{DynamicResource Card}" Margin="0,0,0,4"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="280"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <TextBlock Grid.Column="0" FontSize="12" Text="{Binding Product}" FontWeight="SemiBold" Foreground="Blue" TextDecorations="Underline" Cursor="Hand" VerticalAlignment="Center"/> <ItemsControl Grid.Column="1" ItemsSource="{Binding StatusItems}" VerticalAlignment="Center"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <WrapPanel Orientation="Horizontal"/> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemTemplate> <DataTemplate> <Border Background="{Binding Color}" CornerRadius="8" Padding="6,2" Margin="0,0,4,2"> <TextBlock Text="{Binding Text}" Foreground="{Binding TextColor}" FontSize="12" FontWeight="SemiBold"/> </Border> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </Grid> </Border> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </StackPanel> </Border> <!-- Product Detail Tabs --> <Border Style="{DynamicResource Card}" Margin="0,0,0,8"> <StackPanel Margin="12"> <TextBlock Text="Detailed Product Results" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8"/> <TabControl Name="ProductTabControl" Margin="0,8,0,0"> {PRODUCT_TABS} </TabControl> </StackPanel> </Border> </StackPanel> </ScrollViewer> "@ # Process the data and replace placeholders $processedXaml = $xamlTemplate # Replace tenant information placeholders with proper XML escaping function XmlEscape([string]$s) { if ($null -eq $s) { return "" } $s = [string]$s # Remove control characters that are illegal in XML $s = $s -replace '[\x00-\x08\x0B\x0C\x0E-\x1F]', '' # Replace special XML characters with entities $s = $s -replace '&', '&' $s = $s -replace '<', '<' $s = $s -replace '>', '>' $s = $s -replace '"', '"' $s = $s -replace "'", ''' return $s } $displayName = XmlEscape([string]$ScubaData.MetaData.DisplayName) $domainName = XmlEscape([string]$ScubaData.MetaData.DomainName) $tenantId = XmlEscape([string]$ScubaData.MetaData.TenantId) $scubaVersion = XmlEscape($(if ($ScubaData.MetaData.ToolVersion) { [string]$ScubaData.MetaData.ToolVersion } else { "Unknown" })) $reportUuid = XmlEscape($(if ($ScubaData.MetaData.ReportUUID) { [string]$ScubaData.MetaData.ReportUUID } else { "Unknown" })) $processedXaml = $processedXaml -replace '{DISPLAY_NAME}', $displayName $processedXaml = $processedXaml -replace '{DOMAIN_NAME}', $domainName $processedXaml = $processedXaml -replace '{TENANT_ID}', $tenantId $processedXaml = $processedXaml -replace '{REPORT_DATE}', (XmlEscape([string]$RelativeTime)) $processedXaml = $processedXaml -replace '{SCUBA_VERSION}', $scubaVersion $processedXaml = $processedXaml -replace '{REPORT_UUID}', $reportUuid # Generate product tabs $productTabsXaml = "" foreach ($productAbbr in ($ScubaData.Summary | Get-Member -MemberType NoteProperty).Name) { # Try to get display name from UIConfigs first, fallback to switch statement $productDisplayName = $syncHash.UIConfigs.products | Where-Object { $_.Id -eq $productAbbr } | Select-Object -ExpandProperty Name # Escape the product display name for XML $safeProductDisplayName = $productDisplayName -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' # Generate groups content for this product $groupsContent = "" if ($ScubaData.Results.$productAbbr) { foreach ($group in $ScubaData.Results.$productAbbr) { $groupNumber = if ($group.GroupNumber) { [string]$group.GroupNumber } else { "" } $groupName = if ($group.GroupName) { [string]$group.GroupName } else { "Unknown Group" } $groupHeader = if ($groupNumber) { "$groupNumber. $groupName" } else { $groupName } # Escape XML characters $safeGroupHeader = $groupHeader -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' # Generate controls for this group $controlsContent = "" if ($group.Controls) { foreach ($control in $group.Controls) { $controlId = if ($control."Control ID") { [string]$control."Control ID" } else { "Unknown" } $result = if ($control.Result) { [string]$control.Result } else { "N/A" } $criticality = if ($control.Criticality) { [string]$control.Criticality } else { "" } $requirement = if ($control.Requirement) { [string]$control.Requirement } else { "" } # Truncate requirement if too long if ($requirement.Length -gt 100) { $requirement = $requirement.Substring(0, 100) + "..." } # Color based on result $resultColor = switch ($result) { "Pass" { "#28a745" } "Fail" { "#dc3545" } "Warning" { "#ffc107" } "Manual" { "#6f42c1" } "Error" { "#fd7e14" } default { "#6c757d" } } # Escape XML characters more thoroughly $safeControlId = $controlId -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' $safeResult = $result -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' $safeCriticality = $criticality -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' $safeRequirement = $requirement -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' $controlsContent += @" <Border BorderBrush="LightGray" BorderThickness="1" Margin="0,2" Padding="8" CornerRadius="3"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="120"/> <ColumnDefinition Width="60"/> <ColumnDefinition Width="150"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <TextBlock Grid.Column="0" Text="$safeControlId" FontWeight="SemiBold" VerticalAlignment="Top"/> <TextBlock Grid.Column="1" Text="$safeResult" Foreground="$resultColor" VerticalAlignment="Top"/> <TextBlock Grid.Column="2" Text="$safeCriticality" VerticalAlignment="Top"/> <TextBlock Grid.Column="3" Text="$safeRequirement" TextWrapping="Wrap" VerticalAlignment="Top"/> </Grid> </Border> "@ } } if ([string]::IsNullOrWhiteSpace($controlsContent)) { $controlsContent = @" <TextBlock Text="No control data available" FontStyle="Italic" Foreground="Gray" Margin="8"/> "@ } $groupsContent += @" <Expander Header="$safeGroupHeader" Margin="0,0,0,8" IsExpanded="False"> <StackPanel Margin="16,8,8,8"> $controlsContent </StackPanel> </Expander> "@ } } if ([string]::IsNullOrWhiteSpace($groupsContent)) { $groupsContent = @" <TextBlock Text="No group data available for this product" FontStyle="Italic" Foreground="Gray" Margin="8"/> "@ } $productTabsXaml += @" <TabItem Header="$safeProductDisplayName"> <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto" Margin="8"> <StackPanel> $groupsContent </StackPanel> </ScrollViewer> </TabItem> "@ } # Replace the product tabs placeholder $processedXaml = $processedXaml -replace '\{PRODUCT_TABS\}', $productTabsXaml # Sanitize XAML before parsing to prevent XML entity errors # Remove any illegal control characters if ($processedXaml -match '[\x00-\x08\x0B\x0C\x0E-\x1F]') { Write-DebugOutput -Message "Warning: Removing illegal control characters from XAML" -Source $MyInvocation.MyCommand -Level "Warning" $processedXaml = $processedXaml -replace '[\x00-\x08\x0B\x0C\x0E-\x1F]', '' } # Parse the XAML into a WPF control try { # Add debug logging before parsing Write-DebugOutput -Message "About to parse XAML of length: $($processedXaml.Length)" -Source $MyInvocation.MyCommand -Level "Debug" try { $reportControl = [Windows.Markup.XamlReader]::Parse($processedXaml) } catch [System.Xml.XmlException] { # Provide detailed context to help diagnose the malformed XML $xmlEx = $_.Exception Write-DebugOutput -Message "XmlException parsing XAML: $($xmlEx.Message) at Line $($xmlEx.LineNumber), Position $($xmlEx.LinePosition)" -Source $MyInvocation.MyCommand -Level "Error" # Dump the XAML to a temp file for debugging $dumpPath = Join-Path ([IO.Path]::GetTempPath()) ("ScubaGear_FailingReport_{0}.xaml" -f ([Guid]::NewGuid().ToString())) try { Set-Content -Path $dumpPath -Value $processedXaml -Encoding UTF8 -ErrorAction Stop Write-DebugOutput -Message "Dumped failing XAML to: $dumpPath" -Source $MyInvocation.MyCommand -Level "Error" } catch { Write-DebugOutput -Message "Failed to write XAML dump: $($_.Exception.Message)" -Source $MyInvocation.MyCommand -Level "Error" } # Try a conservative sanitization: escape stray '&' characters and retry once $sanitized = $processedXaml -replace '&(?!amp;|lt;|gt;|quot;|apos;|#\d+;)', '&' Write-DebugOutput -Message "Attempting to parse sanitized XAML (escaped stray ampersands)" -Source $MyInvocation.MyCommand -Level "Warning" try { $reportControl = [Windows.Markup.XamlReader]::Parse($sanitized) Write-DebugOutput -Message "Successfully parsed sanitized XAML after fallback" -Source $MyInvocation.MyCommand -Level "Debug" } catch { Write-DebugOutput -Message "Sanitized XAML parse failed: $($_.Exception.Message)" -Source $MyInvocation.MyCommand -Level "Error" throw $xmlEx # Rethrow the original XML exception with context } } Write-DebugOutput -Message "Successfully parsed simplified XAML report content" -Source $MyInvocation.MyCommand -Level "Debug" # Set up button event handlers $openHtmlBtn = $reportControl.FindName("OpenHtmlBtn") $openFolderBtn = $reportControl.FindName("OpenFolderBtn") $openYamlBtn = $reportControl.FindName("OpenYamlBtn") if ($openHtmlBtn) { $openHtmlBtn.Add_Click({ #minimize main window $syncHash.Window.WindowState = [System.Windows.WindowState]::Minimized $htmlFile = Get-ChildItem -Path $ReportPath -Name "*.html" | Select-Object -First 1 if ($htmlFile) { $htmlPath = Join-Path $ReportPath $htmlFile Start-Process $htmlPath } else { $syncHash.ShowMessageBox.Invoke($syncHash.UIConfigs.localeErrorMessages.HtmlReportNotFound, $syncHash.UIConfigs.localeTitles.ReportNotFound, [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information) } }.GetNewClosure()) } if ($openFolderBtn) { $openFolderBtn.Add_Click({ Start-Process "explorer.exe" -ArgumentList $ReportPath }.GetNewClosure()) } # YAML Configuration button if ($openYamlBtn) { $yamlConfigPath = Join-Path $ReportPath "ScubaGearConfiguration.yaml" if (Test-Path $yamlConfigPath) { $openYamlBtn.Visibility = "Visible" $openYamlBtn.Add_Click({ try { Show-ConfigurationViewer -ConfigFilePath $yamlConfigPath } catch { $syncHash.ShowMessageBox.Invoke($syncHash.UIConfigs.localeErrorMessages.ConfigurationViewerError -f $_.Exception.Message, $syncHash.UIConfigs.localeTitles.ConfigurationViewerError, [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) } }.GetNewClosure()) } } # Populate summary data (this is fast) $summaryItems = @() foreach ($productAbbr in ($ScubaData.Summary | Get-Member -MemberType NoteProperty).Name) { $productData = $ScubaData.Summary.$productAbbr $displayName = $syncHash.UIConfigs.products | Where-Object { $_.Id -eq $productAbbr } | Select-Object -ExpandProperty Name # Build status badges quickly $statusItems = @() $passes = [string]$productData.Passes $warnings = [string]$productData.Warnings $failures = [string]$productData.Failures $manual = [string]$productData.Manual $errors = [string]$productData.Errors if ([int]$passes -gt 0) { $statusItems += [PSCustomObject]@{ Text = "PASS: $passes pass$(if([int]$passes -gt 1){'es'})"; Color = "#28a745"; TextColor = "White" } } if ([int]$warnings -gt 0) { $statusItems += [PSCustomObject]@{ Text = "WARN: $warnings warning$(if([int]$warnings -gt 1){'s'})"; Color = "#ffc107"; TextColor = "#212529" } } if ([int]$failures -gt 0) { $statusItems += [PSCustomObject]@{ Text = "FAIL: $failures failure$(if([int]$failures -gt 1){'s'})"; Color = "#dc3545"; TextColor = "White" } } if ([int]$manual -gt 0) { $statusItems += [PSCustomObject]@{ Text = "MANUAL: $manual manual check$(if([int]$manual -gt 1){'s'})"; Color = "#6f42c1"; TextColor = "White" } } if ([int]$errors -gt 0) { $statusItems += [PSCustomObject]@{ Text = "ERROR: $errors error$(if([int]$errors -gt 1){'s'})"; Color = "#fd7e14"; TextColor = "White" } } $summaryItems += [PSCustomObject]@{ Product = $displayName; ProductAbbr = $productAbbr; StatusItems = $statusItems } } $summaryItemsControl = $reportControl.FindName("SummaryItemsControl") if ($summaryItemsControl) { $summaryItemsControl.ItemsSource = $summaryItems # Wire up click handlers on the product-name TextBlocks after the items are rendered $productTabControl = $reportControl.FindName("ProductTabControl") $summaryItemsControl.Add_Loaded({ # Walk each generated item container and find the blue product TextBlock foreach ($item in $summaryItemsControl.Items) { $container = $summaryItemsControl.ItemContainerGenerator.ContainerFromItem($item) if (-not $container) { continue } # FindVisualChild helper: first TextBlock with TextDecorations $queue = [System.Collections.Generic.Queue[System.Windows.DependencyObject]]::new() $queue.Enqueue($container) while ($queue.Count -gt 0) { $node = $queue.Dequeue() if ($node -is [System.Windows.Controls.TextBlock] -and $node.TextDecorations -and $node.TextDecorations.Count -gt 0) { $capturedItem = $item $capturedTabs = $productTabControl $node.Add_MouseLeftButtonUp({ if (-not $capturedTabs) { return } for ($i = 0; $i -lt $capturedTabs.Items.Count; $i++) { $tab = $capturedTabs.Items[$i] if ($tab.Header -eq $capturedItem.Product) { $capturedTabs.SelectedIndex = $i $tab.BringIntoView() break } } }.GetNewClosure()) break } $childCount = [System.Windows.Media.VisualTreeHelper]::GetChildrenCount($node) for ($ci = 0; $ci -lt $childCount; $ci++) { $queue.Enqueue([System.Windows.Media.VisualTreeHelper]::GetChild($node, $ci)) } } } }.GetNewClosure()) } return $reportControl } catch { Write-DebugOutput -Message "Error parsing XAML: $($_.Exception.Message)" -Source $MyInvocation.MyCommand -Level "Error" # Save the processed XAML to a temp file for debugging $dumpPath = $null try { $dumpPath = Join-Path ([IO.Path]::GetTempPath()) ("ScubaGear_FailingReport_{0}.xaml" -f ([Guid]::NewGuid().ToString())) Set-Content -Path $dumpPath -Value $processedXaml -Encoding UTF8 -ErrorAction Stop Write-DebugOutput -Message "Dumped failing XAML to: $dumpPath" -Source $MyInvocation.MyCommand -Level "Error" } catch { Write-DebugOutput -Message "Failed to write XAML dump: $($_.Exception.Message)" -Source $MyInvocation.MyCommand -Level "Error" } $errorMessage = "Error creating simplified report: $($_.Exception.Message)" if ($dumpPath) { $errorMessage += "`n`nFailing XAML saved to: $dumpPath" } return New-ResultsNoDataTab -ReportPath $ReportPath -Message $errorMessage } } Function New-ResultsGroupExpanderXaml { [OutputType([System.Windows.UIElement])] param( [Parameter(Mandatory=$true)] $GroupData ) try { # Simple validation if (-not $GroupData) { Write-DebugOutput -Message "GroupData is null, returning null" -Source $MyInvocation.MyCommand -Level "Error" return $null } # Create XAML for group expander $expanderXaml = @" <Expander xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" Header="🔒 {GROUP_HEADER}" Margin="0,0,0,8" IsExpanded="False"> <StackPanel Margin="16,8,8,8"> {CONTROLS_CONTENT} </StackPanel> </Expander> "@ # Build header - simple string conversion with fallbacks $groupNumber = if ($GroupData.GroupNumber) { [string]$GroupData.GroupNumber } else { "" } $groupName = if ($GroupData.GroupName) { [string]$GroupData.GroupName } else { "Unknown Group" } $header = if ($groupNumber) { "$groupNumber. $groupName" } else { $groupName } # Escape special XML characters in header $safeHeader = $header -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' # Build controls content $controlsContent = "" if ($GroupData.Controls) { foreach ($control in $GroupData.Controls) { try { # Safe string extraction with simple fallbacks $controlId = if ($control."Control ID") { [string]$control."Control ID" } else { "Unknown" } $result = if ($control.Result) { [string]$control.Result } else { "N/A" } $criticality = if ($control.Criticality) { [string]$control.Criticality } else { "" } $requirement = if ($control.Requirement) { [string]$control.Requirement } else { "" } # Truncate requirement $truncatedRequirement = if ($requirement.Length -gt 100) { $requirement.Substring(0, 100) + "..." } else { $requirement } # Escape special XML characters in all text $safeControlId = $controlId -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' $safeResult = $result -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' $safeCriticality = $criticality -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' $safeRequirement = $truncatedRequirement -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' # Color for result $resultColor = switch ($result) { "Pass" { "Green" } "Fail" { "Red" } "Warning" { "Orange" } "Error" { "Purple" } "N/A" { "Gray" } "Manual" { "Blue" } default { "Black" } } $controlsContent += @" <Border BorderBrush="LightGray" BorderThickness="1" Margin="0,2" Padding="8" CornerRadius="3"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="120"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <TextBlock Grid.Column="0" Text="$safeControlId" FontWeight="SemiBold" VerticalAlignment="Top"/> <TextBlock Grid.Column="1" Text="$safeResult" Foreground="$resultColor" VerticalAlignment="Top"/> <TextBlock Grid.Column="2" Text="$safeCriticality" VerticalAlignment="Top"/> <TextBlock Grid.Column="3" Text="$safeRequirement" TextWrapping="Wrap" VerticalAlignment="Top"/> </Grid> </Border> "@ } catch { Write-DebugOutput -Message "Error processing control: $($_.Exception.Message)" -Source $MyInvocation.MyCommand -Level "Error" # Skip this control and continue continue } } } # If no controls were processed successfully, add a placeholder if ([string]::IsNullOrWhiteSpace($controlsContent)) { $controlsContent = @" <TextBlock Text="No control data available" FontStyle="Italic" Foreground="Gray" Margin="8"/> "@ } # Replace placeholders $processedXaml = $expanderXaml -replace '\{GROUP_HEADER\}', $safeHeader $processedXaml = $processedXaml -replace '\{CONTROLS_CONTENT\}', $controlsContent # Debug: Log the processed XAML length for troubleshooting Write-DebugOutput -Message "Processed XAML length: $($processedXaml.Length) characters for group: $safeHeader" -Source $MyInvocation.MyCommand -Level "Debug" # Parse and return - ensure single object Write-DebugOutput -Message "Creating group expander for: $safeHeader" -Source $MyInvocation.MyCommand -Level "Info" $parsedControl = [Windows.Markup.XamlReader]::Parse($processedXaml) # Ensure we return a single control object, not an array if ($parsedControl -is [System.Array]) { Write-DebugOutput -Message "XAML parser returned array, taking first element" -Source $MyInvocation.MyCommand -Level "Debug" return $parsedControl[0] } else { Write-DebugOutput -Message "XAML parser returned single object of type: $($parsedControl.GetType().FullName)" -Source $MyInvocation.MyCommand -Level "Debug" return $parsedControl } } catch { Write-DebugOutput -Message "Error creating group expander: $($_.Exception.Message)" -Source $MyInvocation.MyCommand -Level "Error" #return New-ResultsNoDataTab } } Function New-ResultsNoDataTab { <# .SYNOPSIS Creates a "No Data" tab for the results view using XAML. #> [OutputType([System.Windows.UIElement])] param( [string]$ReportPath, [string]$Message ) Write-DebugOutput -Message "Creating NoDataTab for reason: $Message, ReportPath: $ReportPath" -Source $MyInvocation.MyCommand -Level "Debug" # Escape any special XML characters in the message text $safeMessageText = $Message -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' Write-DebugOutput -Message "NoDataText message set to: $($safeMessageText.Substring(0, [Math]::Min(100, $safeMessageText.Length)))..." -Source $MyInvocation.MyCommand -Level "Debug" # Create XAML template for the no data content $xamlTemplate = @" <ScrollViewer xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"> <StackPanel Margin="20" HorizontalAlignment="Center" VerticalAlignment="Center"> <TextBlock Text="$safeMessageText" FontSize="16" TextAlignment="Center" Foreground="{DynamicResource MutedTextBrush}" TextWrapping="Wrap" MaxWidth="600" Margin="10"/> </StackPanel> </ScrollViewer> "@ try { # Parse the XAML into a WPF control $noDataControl = [Windows.Markup.XamlReader]::Parse($xamlTemplate) Write-DebugOutput -Message "NoDataTab XAML parsed successfully with type: $($noDataControl.GetType().FullName)" -Source $MyInvocation.MyCommand -Level "Debug" # Return the parsed control directly return $noDataControl } catch { Write-DebugOutput -Message "Error parsing NoDataTab XAML: $($_.Exception.Message)" -Source $MyInvocation.MyCommand -Level "Error" } } Function Update-ResultsCount { <# .SYNOPSIS Updates the results count badge in the Results tab header. #> param([int]$Count) $syncHash.ResultsCountText.Text = if ($Count -eq 0) { "No Reports" } elseif ($Count -eq 1) { "1 Report" } else { "$Count Reports" } } Function Get-ResultsRelativeTime { <# .SYNOPSIS Returns a human-readable relative time string. #> param([DateTime]$DateTime) $now = Get-Date $timespan = $now - $DateTime if ($timespan.TotalDays -lt 1) { if ($timespan.TotalHours -lt 1) { if ($timespan.TotalMinutes -lt 1) { return "Just now" } else { return "$([math]::Floor($timespan.TotalMinutes)) minutes ago" } } else { return "$([math]::Floor($timespan.TotalHours)) hours ago" } } elseif ($timespan.TotalDays -lt 7) { return "$([math]::Floor($timespan.TotalDays)) days ago" } else { return $DateTime.ToString("MMM dd, yyyy") } } Function Open-ResultsFolder { <# .SYNOPSIS Opens the main results folder in Windows Explorer. #> $outputPath = $syncHash.AdvancedSettingsData["OutPath"] if ([string]::IsNullOrEmpty($outputPath) -or $outputPath.Trim() -eq ".") { $outputPath = [Environment]::GetFolderPath('MyDocuments') } if (Test-Path $outputPath) { Start-Process "explorer.exe" -ArgumentList $outputPath } else { $syncHash.ShowMessageBox.Invoke( "Results folder not found: $outputPath", "Folder Not Found", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning ) } } # SIG # Begin signature block # MIIu9gYJKoZIhvcNAQcCoIIu5zCCLuMCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCADWwNpEcRB7QDQ # a6eEeR28FFD3MrmABZL5lN82Gtf5j6CCE6MwggWQMIIDeKADAgECAhAFmxtXno4h # MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV # BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z # ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB # AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z # G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ # anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s # Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL # 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb # BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3 # JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c # AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx # YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0 # viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL # T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud # EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf # Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk # aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS # PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK # 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB # cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp # 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg # dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri # RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7 # 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5 # nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3 # i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H # EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G # CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz # ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C # 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce # 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da # E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T # SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA # FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh # D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM # 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z # 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05 # huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY # mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP # /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T # AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD # VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG # A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV # HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU # cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN # BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry # sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL # IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf # Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh # OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh # dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV # 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j # wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH # Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC # XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l # /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW # eE4wggdXMIIFP6ADAgECAhAMM6tnPejLgA9WVhXroQvSMA0GCSqGSIb3DQEBCwUA # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz # ODQgMjAyMSBDQTEwHhcNMjYwMTE0MDAwMDAwWhcNMjcwMTEzMjM1OTU5WjBfMQsw # CQYDVQQGEwJVUzEdMBsGA1UECBMURGlzdHJpY3Qgb2YgQ29sdW1iaWExEzARBgNV # BAcTCldhc2hpbmd0b24xDTALBgNVBAoTBENJU0ExDTALBgNVBAMTBENJU0EwggIi # MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCuXYolNHqlh6smLTE592waXheZ # 8VHzxeds4pMaepGuwmjf8d1jG9wUNuJX9/qb0a1dgGz5D/EAz5NRTIin4SZYQEE8 # qvdl2yQJ5uWxXIjsFbrOyc1fWscUXw0Kt7OPLOafcEkdDoe8K0tO4h2GL3RWRzjp # uLfQhhnAmD6NT1l+ughnfmarV/ODgIn/RFR4YORlu4YP2xQX6KRxeTDslg7F+z6X # +t87/U8m8gQ9XTm5kBmteP4GcE/ytnyI+ScIxNRybzGomWIBm848XDE5yYhlYQ2R # SnCoo6M4CRqp9WFGVyoLkoPP0OlxzryKWaE1/nuPbYG/kf/rUB1OhqxvSSGwmNhs # vkkjsC0Z9H5Jy6heFdoxOu/+ZQksKoP/fMvHxuCCtkIJbV8tk0oT6MQ8EJbgsWDZ # TKhui1wxW6JIZyBOMPWoZUOouOzo2h5Cz7LBPKME5FkcUzcs47lpRlDkJco4PLcj # wJSo4XPnx3G/2DIjNEFNyfKCWfH8uW6nJjmDBiveFZ2j0YvgdQ+7MOjQnw7R/MAD # DTagrKl3rLV60+X2TY6/onKhCUuU3pMAjVbOwZ3PkzDLZnsEGRfm6hgp6014aXml # t8h4nu+uC41U8vUSGHl0vqKuzvmShLmnI+Iv0l95pmnqomuCZzRDrjEoaLPx7OxL # Dy/Id9E7yQDip4jBdQIDAQABo4ICAzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8R # hvv+YXsIiGX0TkIwHQYDVR0OBBYEFN30sVU+fpfQQfPQWMhkO1qd+MxoMD4GA1Ud # IAQ3MDUwMwYGZ4EMAQQBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNl # cnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMw # gbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp # Z2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5j # cmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0 # ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3JsMIGUBggrBgEF # BQcBAQSBhzCBhDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29t # MFwGCCsGAQUFBzAChlBodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNl # cnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJ # BgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQA77T42YiOx5wWPItgo+kvB+Gzb # ZFCRHRFfTAZZNQt9o/0CqNmyA2xFklX4t5Z9VNdiIOx14AnmJdQkcRdk3vsU5gby # jKEup7LTWtvcWrl6hQwGNt3l892BgUbPKsPBE+AriktVqn5yMSXVVzeboqsqAG8e # Syei0B54/QdgR4whfHvQ/qpCACsJTlJgAykXVgDPJNKnQ7wc17loLQutqF1JUcbO # XKWt1AA9Zas5q30LDZzZeK4B56yojK68CTQXN7toSRFIuZDMKKDfIZpCX8cmbaaO # DFOOu44/QWMv+Xc6+ISYGkrTTzqWhOqiXgLVBeXGn/WrOJJ8R29mZMneCpBesCLs # YII1gCFOo7Vt6mvOKxAPQ3KhJYBFEHkp+GI65koaQkO2xv50iLS0+/j2YC66uviU # MFe0JEOdXuE7Rn/OmWNSzQ+6kPNYDJQASQ974C3wUejJoMtGZEzoTbly/HufQTrd # rhcL2aC69CxSN+idTXPLC9UT3xo4sFdOw+hXkmbXtoB1GDsd5p1TWFgRbnTXDkbM # YMBWYVB6/Tk1bzwj4iTp4g0YrtB628FXPX/ko+JWZlv0Ea865S23w1uGlnDNVxIi # +8oi74G5DM66Q6ENt6+3WoRGRrdoyE6uCh1haY+oPYSgumb0ozzrp8tw89TRKVrK # KSXGBxlExEvjQ6dYwjGCGqkwghqlAgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNV # BAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0 # IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENBMQIQDDOrZz3oy4AP # VlYV66EL0jANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgACh # AoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAM # BgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCDO5PZtd01farpfGJRJCbgR7vHc # Sn7VKEThytwhV3nFpzANBgkqhkiG9w0BAQEFAASCAgCPsDYZAoUt8kIf9w8CtNai # lYZSCfMlUcTpeHPlx8SCpmDDqBc1/3F59Jl60S0l3v6rqNcp8KWxcIbrxlb7OdAn # ef503RjSQUHAUkqFi9Iv6A3dTHDYw7pFytxZPxB1hCPCnIuCXR1TIAS/cnae5VXI # zKKxoXhBPAqAUrYWjnIZX4Y97oLg59xGR6mkjqd+UiN+PO7KK9Xe/Blyvbp6xEUs # qFh8KDSnTzcjmTsD6vn3BCnrjKC1lKhbdqMewK459KVyKdP1qfOqM7mrmvqNMogr # DcqqTW/MJKMutNWMRJDqG1BjQC+zvP43UG4kZE/lH3aUoZViS9H3u3Acf2kIjFT2 # LPiUFfD2kPwxA+9xVqF5D45hL/jv3HQtdV7hqmZJ3IMy8GEDovDDNj8mlNNlP7Hh # OK1J548v+lStASPwSIW5n0BW9yyuCefgkfy+fk2il18yEUYpFLtMXQj6hvgIftUU # 9sJK39kRR2LMaqkIb8eTvmIuUNWkD/UcysAp8D0TmyhV7rExoL3mdlNJVOqekfT0 # VBVwrCPk5y5hqRZRmeU+XCW8/ZY+CC535o68CANOq9ZL+j9xIfPPgeZeSEvjDsYw # fCV9BRgXn9IJ6lUX8qqVJ4sJAu19Yxz2NEPjOKHCTMn+7don5rNzeX5WeMtLDwl4 # qCYjIT4z5EMpjQijh0QOWaGCF3YwghdyBgorBgEEAYI3AwMBMYIXYjCCF14GCSqG # SIb3DQEHAqCCF08wghdLAgEDMQ8wDQYJYIZIAWUDBAIBBQAwdwYLKoZIhvcNAQkQ # AQSgaARmMGQCAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFlAwQCAQUABCDrXnL4OBST # 0t9NjtdA+oHY2VVkIhjm6oG0t9KqvkuKWgIQYwGa/tv/ozm6GYXypHH9RBgPMjAy # NjEwMjIyMTMwNDVaoIITOjCCBu0wggTVoAMCAQICEAhP3DNPfkVO28MPj/mSGDUw # DQYJKoZIhvcNAQELBQAwaTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0 # LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGlu # ZyBSU0E0MDk2IFNIQTI1NiAyMDI1IENBMTAeFw0yNjA4MDUwMDAwMDBaFw0zNzEx # MDQyMzU5NTlaMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5j # LjE7MDkGA1UEAxMyRGlnaUNlcnQgU0hBMjU2IFJTQTQwOTYgVGltZXN0YW1wIFJl # c3BvbmRlciAyMDI2IDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2 # e6byyf7NSvjUm0xls/04xjD4fAkOkbnGQi7+Wpx81iYxfzViaxSIctuH3KSl5YEY # pMuFgGsA31N2D9ATMbfZdw5uaAhuWevQKhDdZIB4NnqcfpfpWQXJiQnDdAElETC+ # bhSEvNLGbA8DtwUpFMQ4yyYQSPqomT92osQAv6hBi47ATZS6JfVWe6XxhF4jJZ3i # SAuf2Cros1czRSmWRHqMv9AfGZvp8ygYElhudpQjtcPpwoOl6QrZJUyV3iINvN4c # O05prGV0fkjG426xDr2d3z9lcSIHkdvGPdGUrXdxfVbgOUVcp2/8ISEzwKPW++Wa # +E2ujI91EZtukGWDJ/xZ27k3oHKEXBRGfRTqjOU+jE3ba/5++JSE/7oNHnjs5mek # ExYN96LV/mxUbCKJb8pBNY4r3uD7hEmk/M81XhVgwDA7aMzYC3LZBg9WY5BMmbSa # y5ecmtJuXaB/0nKWmQmVZeqTVDgsmzHP5MQuhAJkiWNuC9MmCg9TZHXbJ2/yLVSo # v9p16UDTLtT0+aa1vN71fHeu1qMLlLNB3WOB/ADCxr3S/1hxI92Z6jKgEED/btwI # vbfuXkNNhg8MtDg43c4tMZae9FvqMOt/9PvmAxF9TNIsIFB8G6yb36ZJZGUL8N/p # L971DyLXcK6HM5PYnH5X+eVtczhCgHCVQCF6XDAlPQIDAQABo4IBlTCCAZEwDAYD # VR0TAQH/BAIwADAdBgNVHQ4EFgQUFMljijAu1Er7bpTz5uNAfvXszeIwHwYDVR0j # BBgwFoAU729TSunkBnx6yuKQVvYv1Ensy04wDgYDVR0PAQH/BAQDAgeAMBYGA1Ud # JQEB/wQMMAoGCCsGAQUFBwMIMIGVBggrBgEFBQcBAQSBiDCBhTAkBggrBgEFBQcw # AYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMF0GCCsGAQUFBzAChlFodHRwOi8v # Y2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBp # bmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5jcnQwXwYDVR0fBFgwVjBUoFKgUIZOaHR0 # cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0VGltZVN0YW1w # aW5nUlNBNDA5NlNIQTI1NjIwMjVDQTEuY3JsMCAGA1UdIAQZMBcwCAYGZ4EMAQQC # MAsGCWCGSAGG/WwHATANBgkqhkiG9w0BAQsFAAOCAgEAjcU6YR6dUgrfmawJgH59 # KECxa9Ji8sEi2g10CBDaMiqsaxWyW5cwlT/6ZF5sFznazqVsoC85U9dqLOYqQwst # +UQQoNlDHgKRLa3xoc+OReFreFhnTXSG0Vrd2E2CZqUfm+5a+He1MJ/h+tNLuA+0 # Zzhn/Fo+FDYAHWZHx4R79ZsfRFYe9UiXpXBDf6DkUo183Y38NYmR/XfDYf7YZ+oR # 9t3flbDwK+hgGMs0gNNp1w9Z2CyOyI5or/sSwomAuNQ0hWC9xoU4stD8aWsD7Rkc # mgVRs6vlIk3zPKQ+ylcheWkMlj+CoVRlFE55pv0ZWCaFt04lwP/rdGHE9qEVQZty # RE42ox7oNgC/r+Y4bSlZ3dw9K2x1xLtu6PkPKeLBFjzKigwfqm3Hm+k/+lnME8F5 # kPZTgiy2HLEHklpryqs6QHnPXrRNeIzkAMyylnRN8P0wmirS0WkU+ywpEWFZ4QNg # +9xS43tTuW9x0eXh7NDc1P/sV+zWxHXKH8tFt1ncHdVzqrZaYPyYMLSn2TOXajve # JW1L3joiQSPsWRGxkbDDW15jERFE4LvjnGu2O9zD1nLJSMdlYZEikl4w2w+q4IN/ # R+TIe0H4ngCI1moJCTbevGH4punIxM1Uoi0nmX3ZK+XbRT01uowE5ViXWHng0Rgs # mrX/EdYUo80r3TfMlkD0/YMwgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmG # MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD # ZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5 # NTlaMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8G # A1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBT # SEEyNTYgMjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0 # eDHTCphBcr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+Ruw # OnPhof6pvF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4B # t0mAxAHeHYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1U # kxBvMgEdgkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTca # arps0wjUjsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zb # CclF83bRVFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnG # pTXiUOeSLsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/ # AMOMCZIVNSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v # 5ydPpOjL6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoi # wOrUG2ZdSoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm # 2qA+sdFUeEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYD # VR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04w # HwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGG # MBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcw # AYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8v # Y2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBD # BgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNl # cnRUcnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgB # hv1sBwEwDQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Q # h8WIGjB/T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3 # YTj+IQhQE7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQ # wr8Myb9rEVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/ # wdG2th9y1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81 # hjajV/gxdEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00 # TYr2Lr3ty9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNj # qFzeGxcytL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0 # cuJG7uEBYTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9 # sC+NJpud/v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0 # LckTetiSuEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2 # tszWkPZPubdcMIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAYWjANBgkqhkiG # 9w0BAQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkw # FwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1 # cmVkIElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5MjM1OTU5WjBi # MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 # d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg # RzQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAi # MGkz7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnny # yhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE # 5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm # 7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5 # w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsD # dV14Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1Z # XUJ2h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS0 # 0mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hk # pjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m8 # 00ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+i # sX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0TAQH/BAUwAwEB # /zAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0jBBgwFoAUReui # r/SSy4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsGAQUFBwEBBG0w # azAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMGCCsGAQUF # BzAChjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1cmVk # SURSb290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRpZ2lj # ZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYDVR0gBAowCDAG # BgRVHSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3QbPbYW1/e/Vwe9 # mqyhhyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5+KH38nLeJLxS # A8hO0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+nBgMTdydE1Od/ # 6Fmo8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc/RzY9HdaXFSM # b++hUD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVrzyerbHbObyMt # 9H5xaiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o4rmUMYIDfDCC # A3gCAQEwfTBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4x # QTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQw # OTYgU0hBMjU2IDIwMjUgQ0ExAhAIT9wzT35FTtvDD4/5khg1MA0GCWCGSAFlAwQC # AQUAoIHRMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUx # DxcNMjYxMDIyMjEzMDQ1WjArBgsqhkiG9w0BCRACDDEcMBowGDAWBBRR2avaA0lz # 2E9CZqykgkjms2nEOTAvBgkqhkiG9w0BCQQxIgQgULqE04x/i/6+9LTEgH8tIGDW # vKoo3VhGb+fCjCgqm/QwNwYLKoZIhvcNAQkQAi8xKDAmMCQwIgQgLaCdp/QTH5/n # LbbF5unJZWdVrwQ/HqdCzA0hIOFB6/wwDQYJKoZIhvcNAQEBBQAEggIAQ7gUF7Qc # i52VNTAHfRAh65GrVs/wHj1aAuEe3nk7XvEoOHH6BVQZnr+uevgfBn9IGtNDP00K # BKfob+fgFD5b20kKmGb0AosYOJHUt0XOxQf8guajs3GLS6gObW+G14Q54QVP4K8Y # DpYsAzuawlpHZzbg+Z6kja0G1JQVSgcExJpg+qOz9y5KELZ9fEHwjwHRaS7cU+0H # GgA6RFmAwTTkoUhRBUCmNWrgBUPcl1zt8bWjaFw6t2pN2OJRhvZIVuRvfbULT015 # rKSA11JkTgUCaSZF8v5xMsGmsLzf6aNUFIth/GA2SzwAzBrA+n/T7A15w0zOj8E2 # HpXZsVx5uR94Xp9BUtV5m9kAQ2prxl8XnVMsdZcO8qupp53KlPWkxq1wMb9XoZSy # s7K14miKclxoiE2EF1R7snxAnNMDPU54kHSWJJ1v7rMKc/tS3p/LC5LVtaxSJ30E # iZvQkc95ASFqE9aYQyKv4Uu8ujUOhJlpkYD8iZ8oPLLDetuHusdjaBUuO+Ml4plJ # ZqvGjPb5Qv8M5Ssl/tSCLmWkJy4HPXIUa9WQ2dTrBpNAP6S+tRmEbDL+J4t+rwdP # sEAF38qQiY+NAgkffLfYskWS7Z8/NPHdBsjUZeJMnl1FS6sLxfuRaVAd6hZUAduj # DxOdblqTXiqVFvRawFKOwjx0DA3piOZMMBs= # SIG # End signature block |