Add-AppEventHandler "CompareColumnVisibilityChanged" "Set-CompareGridColumnVisibility" function Set-CompareGridColumnVisibility { param($ShowCategory = $false, $ShowSubCategory = $false) if(-not $script:dgCompareInfo) { return } foreach($col in $script:dgCompareInfo.Columns) { $Path = $col.Binding.Path.Path if($Path -eq "Category") { $col.Visibility = if($ShowCategory) { "Visible" } else { "Collapsed" } } if($Path -eq "SubCategory") { $col.Visibility = if($ShowSubCategory) { "Visible" } else { "Collapsed" } } } } function Set-CompareRuntimeOptionsFromUI { $params = @{} if($script:cbCompareType) { $params.CompareType = $script:cbCompareType.SelectedValue $params.CompareDefinition = $script:cbCompareType.SelectedItem } if($script:chkIgnoreCoreProperties) { $params.IgnoreCoreProperties = [bool]$script:chkIgnoreCoreProperties.IsChecked } if($script:cbCompareSave) { $params.SaveType = $script:cbCompareSave.SelectedValue } if($script:cbCompareOutputFormat) { $params.OutputProvider = $script:cbCompareOutputFormat.SelectedItem } if($script:cbCompareCSVDelimiter) { $params.CsvDelimiter = $script:cbCompareCSVDelimiter.Text } if($script:cbCompareMultiValueDelimiter -and $null -ne $script:cbCompareMultiValueDelimiter.SelectedValue) { $params.ObjectSeparator = [string]$script:cbCompareMultiValueDelimiter.SelectedValue } if($script:chkSkipCompareAssignments) { $params.SkipAssignments = [bool]$script:chkSkipCompareAssignments.IsChecked } Set-CompareRuntimeOptions @params } function Get-CompareMultiValueDelimiterItems { @( [PSCustomObject]@{ Name = "New line"; Value = [System.Environment]::NewLine }, [PSCustomObject]@{ Name = ";"; Value = ";" }, [PSCustomObject]@{ Name = "|"; Value = "|" } ) } function Initialize-CompareSharedOptionControls { # Controls shared by the single and bulk compare forms. if($script:cbCompareMultiValueDelimiter) { $script:cbCompareMultiValueDelimiter.ItemsSource = @(Get-CompareMultiValueDelimiterItems) $script:cbCompareMultiValueDelimiter.SelectedValue = (Get-SettingStoreValue "Compare" "ObjectSeparator" ([System.Environment]::NewLine)) if($null -eq $script:cbCompareMultiValueDelimiter.SelectedItem) { $script:cbCompareMultiValueDelimiter.SelectedIndex = 0 } } if($script:chkSkipCompareAssignments) { $script:chkSkipCompareAssignments.IsChecked = ((Get-SettingStoreValue "Compare" "SkipCompareAssignments" "false") -eq "true") } } function Save-CompareSharedOptionSettings { if($script:cbCompareMultiValueDelimiter -and $null -ne $script:cbCompareMultiValueDelimiter.SelectedValue) { Save-SettingStoreValue "Compare" "ObjectSeparator" $script:cbCompareMultiValueDelimiter.SelectedValue } if($script:chkSkipCompareAssignments) { Save-SettingStoreValue "Compare" "SkipCompareAssignments" $(if($script:chkSkipCompareAssignments.IsChecked) { "true" } else { "false" }) } } # ─── Object Picker ──────────────────────────────────────────────────────────── function Show-ObjectPickerDialog { param( [string] $Title, [object[]] $Items, [object[]] $DisplayColumns, [scriptblock] $LoadHandler, [string] $LoadLabel = "Load all from Intune", # Entries from Get-PolicySearchScopes. When supplied, the dialog shows # a "Search in" dropdown above the search box. $Scopes = $null, [string] $SelectedScopeKey = $null, # Entries from Get-PolicySearchTenants. When supplied, the dialog shows # a "Tenant" dropdown so the user can search a different tenant. $Tenants = $null, [string] $SelectedTenantKey = $null, # Called as & $SearchHandler $searchText $scope and expected to return # the rows to display. When absent the Search button filters the # supplied -Items client-side (the original behaviour). [scriptblock] $SearchHandler = $null ) # Per-call state on $script:_pickerState. Closures below dynamically read # this slot at INVOCATION time instead of capturing locals via # .GetNewClosure() — that captures every variable visible at create time, # including module-scope ones like $script:dgPickerObjects which are # populated AFTER the closures are built (Show-PickerDialog assigns them # when it materialises the dialog). Captured-as-null is what produced the # "You cannot call a method on a null-valued expression" errors at # $script:dgPickerObjects.Columns.Add and the 'cannot be found' error at # $script:dgPickerObjects.ItemsSource. Using a $script: bag keeps lookup # late-bound; assignments by Show-PickerDialog land in the same scope the # closures read from. $script:_pickerState = @{ Columns = $DisplayColumns # ItemsBox lets the LoadHandler replace the list in-place; the apply # path reassigns ItemsBox.Items and every closure picks up the new # array on the next read. ItemsBox = @{ Items = Get-UIItemsArray $Items } Result = @{ Value = $null } SearchHandler = $SearchHandler } $initHandler = { $ps = $script:_pickerState foreach($colDef in $ps.Columns) { $col = [System.Windows.Controls.DataGridTextColumn]::new() $col.Header = $colDef.Header $col.IsReadOnly = $true $col.Binding = [System.Windows.Data.Binding]::new($colDef.Binding) $script:dgPickerObjects.Columns.Add($col) } $script:dgPickerObjects.ItemsSource = $ps.ItemsBox.Items } $clickHandler = { $ps = $script:_pickerState $filter = $script:txtPickerSearch.Text if($ps.SearchHandler) { # Server-side search: ask the caller for rows matching the text in # the selected scope, and show exactly what comes back. $scope = Get-PickerSelectedScope try { $script:pickerDialog.Cursor = [System.Windows.Input.Cursors]::Wait $found = Get-UIItemsArray (& $ps.SearchHandler $filter $scope) } catch { Write-LogError "Picker SearchHandler failed" $_.Exception $found = @() } finally { $script:pickerDialog.Cursor = $null } # The status line belongs to the search handler - it knows whether # an empty result means "no matches" or "keep typing". $ps.ItemsBox.Items = $found $script:dgPickerObjects.ItemsSource = $found return } if([string]::IsNullOrEmpty($filter)) { $script:dgPickerObjects.ItemsSource = $ps.ItemsBox.Items } else { $script:dgPickerObjects.ItemsSource = @($ps.ItemsBox.Items | Where-Object { $_.Name -ilike "*$filter*" }) } } $okHandler = { $script:_pickerState.Result.Value = $script:dgPickerObjects.SelectedItem $script:pickerDialog.Close() } $wrappedLoadHandler = $null if($LoadHandler) { # Stash the user-supplied loader so the wrapper script block can # reach it via $script: at runtime (same late-bind reasoning as # above — no closure capture). $script:_pickerState.UserLoadHandler = $LoadHandler $script:_pickerState.ClickHandler = $clickHandler $wrappedLoadHandler = { $ps = $script:_pickerState $newItems = & $ps.UserLoadHandler if($ps.SearchHandler) { # Scoped loads replace rather than merge - the returned set IS # everything in the newly selected scope, and merging would # leave stale rows from a previous scope in the grid. $ps.ItemsBox.Items = Get-UIItemsArray $newItems $script:dgPickerObjects.ItemsSource = $ps.ItemsBox.Items return } if($newItems) { # Dedupe by Id; new entries appended so cached items stay first. $existingIds = @{} foreach($existing in $ps.ItemsBox.Items) { if($existing -and $existing.Id) { $existingIds[$existing.Id] = $true } } $merged = [System.Collections.Generic.List[object]]::new() foreach($existing in $ps.ItemsBox.Items) { [void]$merged.Add($existing) } foreach($n in $newItems) { if(-not $n) { continue } if($n.Id -and $existingIds.ContainsKey($n.Id)) { continue } [void]$merged.Add($n) } $ps.ItemsBox.Items = $merged.ToArray() } & $ps.ClickHandler } } try { Show-PickerDialog -title $Title -initHandler $initHandler -clickHandler $clickHandler -okHandler $okHandler -LoadHandler $wrappedLoadHandler -LoadLabel $LoadLabel -Scopes $Scopes -SelectedScopeKey $SelectedScopeKey -Tenants $Tenants -SelectedTenantKey $SelectedTenantKey return $script:_pickerState.Result.Value } finally { # Avoid keeping the closed-over Items / handlers alive between dialog # invocations; next Show-ObjectPickerDialog call replaces this anyway. $script:_pickerState = $null } } # ─── Bulk Compare ───────────────────────────────────────────────────────────── function Show-GraphBulkCompareForm { $script:bulkCompareForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkCompare.xaml"), $true) if(-not $script:bulkCompareForm) { return } $script:cbCompareProvider.ItemsSource = @($script:compareProviders) $script:cbCompareProvider.SelectedValue = (Get-SettingStoreValue "Compare" "Provider" "export") # A saved provider id that no longer registers (a provider was removed) must # not leave the combo blank - Start would then be a silent no-op. Mirrors the # Avalonia form's first-item fallback. if($null -eq $script:cbCompareProvider.SelectedItem -and $script:cbCompareProvider.Items.Count -gt 0) { $script:cbCompareProvider.SelectedIndex = 0 } $script:cbCompareSave.ItemsSource = @($script:compareOutputTypes) $script:cbCompareSave.SelectedValue = (Get-SettingStoreValue "Compare" "SaveType" "objectType") $script:cbCompareOutputFormat.ItemsSource = @($script:compareOutputProviders) $script:cbCompareOutputFormat.SelectedValue = (Get-SettingStoreValue "Compare" "OutputFormat" "csv") $script:cbCompareType.ItemsSource = $script:comparisonTypes $script:cbCompareType.SelectedValue = (Get-SettingStoreValue "Compare" "Type" "property") $script:cbCompareCSVDelimiter.ItemsSource = @("", ",", ";", "-", "|") $script:cbCompareCSVDelimiter.SelectedValue = (Get-SettingStoreValue "Compare" "Delimiter" ";") Initialize-CompareSharedOptionControls if($script:chkIgnoreCoreProperties) { $script:chkIgnoreCoreProperties.IsChecked = ((Get-SettingStoreValue "Compare" "IgnoreCoreProperties" "true") -eq "true") } if($script:chkSkipMissingSourcePolicies) { $script:chkSkipMissingSourcePolicies.IsChecked = ((Get-SettingStoreValue "Compare" "SkipMissingSourcePolicies" "false") -eq "true") } if($script:chkSkipMissingDestinationPolicies) { $script:chkSkipMissingDestinationPolicies.IsChecked = ((Get-SettingStoreValue "Compare" "SkipMissingDestinationPolicies" "false") -eq "true") } $script:compareObjects = @() foreach($intuneGroup in $script:IntuneGroups) { if(-not $intuneGroup.Title) { continue } $script:compareObjects += [PSCustomObject]@{ Title = $intuneGroup.Title Selected = $true ObjectGroup = $intuneGroup } } $column = Get-GridCheckboxColumn "Selected" $script:dgObjectsToCompare.Columns.Add($column) $column.Header.IsChecked = $true $column.Header.Add_Click({ foreach($item in $script:dgObjectsToCompare.ItemsSource) { $item.Selected = $this.IsChecked } $script:dgObjectsToCompare.Items.Refresh() }) $col = [System.Windows.Controls.DataGridTextColumn]::new() $col.Header = "Object type" $col.IsReadOnly = $true $col.Binding = [System.Windows.Data.Binding]::new("Title") $script:dgObjectsToCompare.Columns.Add($col) $script:dgObjectsToCompare.ItemsSource = $script:compareObjects $script:cbCompareOutputFormat.Add_SelectionChanged({ $isCSV = ($script:cbCompareOutputFormat.SelectedItem -is [CompareCSVOutputProvider]) $script:cbCompareCSVDelimiter.IsEnabled = $isCSV }) $script:UIProvider.AddXamlEvent($script:bulkCompareForm, "btnClose", "add_click", { $script:bulkCompareForm = $null $script:UIProvider.ShowModalObject() }) $script:UIProvider.AddXamlEvent($script:bulkCompareForm, "btnStartCompare", "add_click", { Write-Status "Compare objects" Save-SettingStoreValue "Compare" "Provider" $script:cbCompareProvider.SelectedValue Save-SettingStoreValue "Compare" "Type" $script:cbCompareType.SelectedValue Save-SettingStoreValue "Compare" "Delimiter" $script:cbCompareCSVDelimiter.SelectedValue Save-SettingStoreValue "Compare" "OutputFormat" $script:cbCompareOutputFormat.SelectedValue Save-SettingStoreValue "Compare" "SaveType" $script:cbCompareSave.SelectedValue Save-CompareSharedOptionSettings if($script:chkIgnoreCoreProperties) { Save-SettingStoreValue "Compare" "IgnoreCoreProperties" $(if($script:chkIgnoreCoreProperties.IsChecked) { "true" } else { "false" }) } try { Set-CompareRuntimeOptionsFromUI $Provider = $script:cbCompareProvider.SelectedItem if($Provider) { if($script:chkSkipMissingSourcePolicies) { $Provider.SkipMissingSourcePolicies = [bool]$script:chkSkipMissingSourcePolicies.IsChecked Save-SettingStoreValue "Compare" "SkipMissingSourcePolicies" $(if($Provider.SkipMissingSourcePolicies) { "true" } else { "false" }) } if($script:chkSkipMissingDestinationPolicies) { $Provider.SkipMissingDestinationPolicies = [bool]$script:chkSkipMissingDestinationPolicies.IsChecked Save-SettingStoreValue "Compare" "SkipMissingDestinationPolicies" $(if($Provider.SkipMissingDestinationPolicies) { "true" } else { "false" }) } } $selectedGroups = @() if($script:dgObjectsToCompare) { $selectedGroups = @($script:dgObjectsToCompare.ItemsSource | Where-Object Selected -eq $true | ForEach-Object { $_.ObjectGroup }) } if($Provider) { Start-BulkCompare $Provider -SelectedGroups $selectedGroups } } catch { $script:UIProvider.ShowMessageBox($_.Exception.Message, "Compare", "OK", "Error") } finally { Write-Status "" } }) $script:cbCompareProvider.Add_SelectionChanged({ Set-CompareProviderOptions $this }) Set-CompareProviderOptions $script:cbCompareProvider $script:UIProvider.ShowModalForm("Bulk Compare Objects", $script:bulkCompareForm, $true) } function Update-BulkCompareFormForProvider { param($Provider) if(-not $script:grdObjectsToCompareSection) { return } $script:grdObjectsToCompareSection.Visibility = if($Provider -and $Provider.IgnoreGroups) { "Collapsed" } else { "Visible" } } # ─── Single Compare ────────────────────────────────────────────────────────── function Show-GraphCompareForm { param([object[]]$Policies) if(-not $Policies -or $Policies.Count -eq 0) { return } $script:compareForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\CompareForm.xaml"), $true) if(-not $script:compareForm) { return } $script:compareSource = $Policies[0] $script:compareTargetPolicy = if($Policies.Count -ge 2) { $Policies[1] } else { $null } $script:compareInputPanel = $null $script:compareInputPanelCache = @{} $script:cbCompareType.ItemsSource = $script:comparisonTypes $script:cbCompareType.SelectedValue = (Get-SettingStoreValue "Compare" "Type" "property") $script:cbSingleCompareOutputFormat.ItemsSource = @($script:compareOutputProviders) $script:cbSingleCompareOutputFormat.SelectedValue = (Get-SettingStoreValue "Compare" "SingleOutputFormat" "csv") if($script:chkIgnoreCoreProperties) { $script:chkIgnoreCoreProperties.IsChecked = ((Get-SettingStoreValue "Compare" "IgnoreCoreProperties" "true") -eq "true") } Initialize-CompareSharedOptionControls if($script:cbCompareFilter) { # 3-way result filter (All / Mismatch / Match). $savedFilter = Get-SettingStoreValue "Compare" "ResultFilter" "All" $script:cbCompareFilter.SelectedItem = @($script:cbCompareFilter.Items) | Where-Object { "$($_.Tag)" -eq "$savedFilter" } | Select-Object -First 1 if(-not $script:cbCompareFilter.SelectedItem) { $script:cbCompareFilter.SelectedIndex = 0 } $script:cbCompareFilter.Add_SelectionChanged({ Update-CompareGridFilter }) } $script:txtIntuneObject.Text = $Policies[0].Name $compareInputTypes = @( [PSCustomObject]@{ Name = "File"; Value = "file"; OptionsXaml = "CompareFileOptions" } [PSCustomObject]@{ Name = "Intune Object"; Value = "intune"; OptionsXaml = "CompareIntuneObjectOptions" } ) $script:cbCompareInput.ItemsSource = $compareInputTypes $script:cbCompareInput.Add_SelectionChanged({ Set-CompareInputOptions $this }) $script:cbCompareInput.SelectedValue = if($script:compareTargetPolicy) { "intune" } else { (Get-SettingStoreValue "Compare" "InputType" "file") } $script:UIProvider.AddXamlEvent($script:compareForm, "btnClose", "add_click", { $script:compareForm = $null $script:compareSource = $null $script:compareTargetPolicy = $null $script:compareInputPanel = $null $script:compareInputPanelCache = $null $script:chkIgnoreCoreProperties = $null $script:cbCompareFilter = $null $script:chkSkipCompareAssignments = $null $script:cbCompareMultiValueDelimiter = $null $script:txtCompareSummary = $null $script:dgCompareInfo = $null $script:UIProvider.ShowModalObject() }) $script:UIProvider.AddXamlEvent($script:compareForm, "btnStartCompare", "add_click", { Write-Status "Compare objects" Save-SettingStoreValue "Compare" "Type" $script:cbCompareType.SelectedValue Save-SettingStoreValue "Compare" "InputType" $script:cbCompareInput.SelectedValue Save-SettingStoreValue "Compare" "SingleOutputFormat" $script:cbSingleCompareOutputFormat.SelectedValue if($script:chkIgnoreCoreProperties) { Save-SettingStoreValue "Compare" "IgnoreCoreProperties" $(if($script:chkIgnoreCoreProperties.IsChecked) { "true" } else { "false" }) } if($script:cbCompareFilter -and $script:cbCompareFilter.SelectedItem) { Save-SettingStoreValue "Compare" "ResultFilter" ([string]$script:cbCompareFilter.SelectedItem.Tag) } Save-CompareSharedOptionSettings Set-CompareRuntimeOptionsFromUI Start-CompareObjects Write-Status "" }) $script:UIProvider.AddXamlEvent($script:compareForm, "btnSwap", "add_click", { if(-not $script:compareSource -or -not $script:compareTargetPolicy) { return } $tmp = $script:compareSource $script:compareSource = $script:compareTargetPolicy $script:compareTargetPolicy = $tmp $script:txtIntuneObject.Text = $script:compareSource.Name if($script:compareInputPanel) { $txt = $script:compareInputPanel.FindName("txtCompareIntuneObject") if($txt) { $txt.Text = $script:compareTargetPolicy.Name } } }) $script:UIProvider.AddXamlEvent($script:compareForm, "btnCompareSave", "add_click", { if(($script:dgCompareInfo.ItemsSource | Measure-Object).Count -eq 0) { return } $outProv = if($script:cbSingleCompareOutputFormat -and $script:cbSingleCompareOutputFormat.SelectedItem) { $script:cbSingleCompareOutputFormat.SelectedItem } else { [CompareCSVOutputProvider]::new() } $sf = [System.Windows.Forms.SaveFileDialog]::new() $sf.FileName = $script:compareSource.Name $sf.DefaultExt = $outProv.Extension $sf.FilterIndex = if($outProv.Extension -eq "json") { 2 } else { 1 } $sf.Filter = "CSV files (*.csv)|*.csv|JSON files (*.json)|*.json|All files (*.*)|*.*" $initialDir = Get-SettingStoreValue "Compare" "LastSaveDirectory" if(-not $initialDir) { $initialDir = Get-SettingValue "RootFolder" } if($initialDir) { $sf.InitialDirectory = $initialDir } if($sf.ShowDialog() -eq "OK") { Save-SettingStoreValue "Compare" "LastSaveDirectory" ([IO.FileInfo]$sf.FileName).DirectoryName $ext = [IO.Path]::GetExtension($sf.FileName).TrimStart(".") $outProv = Get-CompareOutputProviderByExtension $ext $props = Get-CompareOutputProps $outProv.FormatRows($script:dgCompareInfo.ItemsSource, $props) | Out-File -LiteralPath $sf.FileName -Force -Encoding UTF8 } }) $script:UIProvider.AddXamlEvent($script:compareForm, "btnCompareCopy", "add_click", { Set-CompareRuntimeOptionsFromUI (Get-CompareCsvInfo $script:dgCompareInfo.ItemsSource $script:compareSource) | Set-Clipboard }) $script:UIProvider.ShowModalForm("Compare Intune Objects", $script:compareForm, $true) } function Set-CompareInputOptions { param($Control) $InputType = $Control.SelectedItem if(-not $InputType) { return } $Panel = $null if($script:compareInputPanelCache -is [Hashtable] -and $script:compareInputPanelCache.ContainsKey($InputType.Value)) { $Panel = $script:compareInputPanelCache[$InputType.Value] } elseif($InputType.OptionsXaml) { $Panel = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\$($InputType.OptionsXaml).xaml")) if($Panel) { $script:compareInputPanelCache[$InputType.Value] = $Panel Register-CompareInputBrowseEvents $Panel $InputType } } $script:compareInputPanel = $Panel $script:ccCompareInputOptions.Content = $Panel if($Panel -and $InputType.Value -eq "intune" -and $script:compareTargetPolicy) { $txt = $Panel.FindName("txtCompareIntuneObject") if($txt) { $txt.Text = $script:compareTargetPolicy.Name } } } function Register-CompareInputBrowseEvents { param($Panel, $InputType) $browseFile = $Panel.FindName("browseCompareObject") if($browseFile) { $browseFile.Tag = $Panel $browseFile.Add_Click({ $p = $this.Tag $path = Get-SettingStoreValue "" "LastUsedFullPath" if($path) { $path = [IO.Directory]::GetParent($path).FullName } if($script:compareSource -and $script:compareSource.PolicyType) { # No ?? operator here - this file must parse on PS5.1 too. $typePath = [IO.Path]::Combine($(if($path) { $path } else { "" }), $script:compareSource.PolicyType.Folder) if([IO.Directory]::Exists($typePath)) { $path = $typePath } } $lastFile = Get-SettingStoreValue "Compare" "LastFile" if(-not [String]::IsNullOrEmpty($lastFile)) { $path = ([IO.FileInfo]$lastFile).DirectoryName } $of = [System.Windows.Forms.OpenFileDialog]::new() $of.Multiselect = $false $of.Filter = "Json files (*.json)|*.json" if($path) { $of.InitialDirectory = $path } if($of.ShowDialog() -eq "OK") { $txt = $p.FindName("txtCompareFile") if($txt) { $txt.Text = $of.FileName } Save-SettingStoreValue "Compare" "LastFile" $of.FileName } }) } $browseIntune = $Panel.FindName("browseIntuneObject") if($browseIntune) { $browseIntune.Tag = $Panel $browseIntune.Add_Click({ $p = $this.Tag $sourceType = if($script:compareSource) { $script:compareSource.PolicyType } else { $null } if(-not $sourceType) { $script:UIProvider.ShowMessageBox("Select a source policy first.", "Compare", "OK", "Warning") | Out-Null return } $sourceId = if($script:compareSource) { $script:compareSource.Id } else { $null } # Seed with same-type policies already cached in the main view so the # dialog opens with something in it and no Graph call. Searching or # "Load all in scope" goes to Graph from there. $initial = @() if($script:intuneManagerPolicyCollection) { $initial = @($script:intuneManagerPolicyCollection | Where-Object { $_.PolicyType -and $_.PolicyType.ID -eq $sourceType.ID -and (-not $sourceId -or $_.Id -ne $sourceId) }) } $exclude = @() if($sourceId) { $exclude = @($sourceId) } $selected = Show-PolicySearchDialog ` -Title "Select Intune Object to Compare ($($sourceType.Title))" ` -DefaultPolicyTypeId $sourceType.ID ` -ExcludeIds $exclude ` -InitialItems $initial if($selected) { $script:compareTargetPolicy = $selected $txt = $p.FindName("txtCompareIntuneObject") if($txt) { $txt.Text = $selected.Name } } }) } } function Start-CompareObjects { if(-not $script:compareSource) { return } $compareMode = if($script:cbCompareInput) { $script:cbCompareInput.SelectedValue } ` elseif($script:compareTargetPolicy) { "intune" } ` else { "file" } if($compareMode -eq "intune") { if(-not $script:compareTargetPolicy) { $script:UIProvider.ShowMessageBox("No Intune object selected for comparison", "Compare", "OK", "Error") return } Write-Status "Compare Intune objects" Resolve-FullPolicyForCompare $script:compareSource Resolve-FullPolicyForCompare $script:compareTargetPolicy $compareResult = Compare-PolicyObjects @($script:compareSource, $script:compareTargetPolicy) $script:dgCompareInfo.ItemsSource = $compareResult Update-CompareGridFilter Update-CompareSummary $tabCtrl = $script:compareForm.FindName("tabCompare") if($tabCtrl) { $tabCtrl.SelectedIndex = 1 } Write-Status "" return } $compareFile = "" if($script:compareInputPanel) { $txtFile = $script:compareInputPanel.FindName("txtCompareFile") if($txtFile) { $compareFile = $txtFile.Text } } if(-not $compareFile) { $script:UIProvider.ShowMessageBox("No file selected for comparison", "Compare", "OK", "Error") return } if(-not [IO.File]::Exists($compareFile)) { $script:UIProvider.ShowMessageBox("File '$compareFile' not found", "Compare", "OK", "Error") return } try { $filePolicy = $script:compareSource.PolicyType.GetObject([IO.FileInfo]$compareFile) if(-not $filePolicy) { $script:UIProvider.ShowMessageBox("Failed to load file '$compareFile'. Object type may not match.", "Compare", "OK", "Error") return } } catch { $script:UIProvider.ShowMessageBox("Failed to load file '$compareFile': $($_.Exception.Message)", "Compare", "OK", "Error") return } Resolve-FullPolicyForCompare $script:compareSource if($script:compareSource.Object.'@OData.Type' -ne $filePolicy.Object.'@OData.Type') { if(($script:UIProvider.ShowMessageBox("The object types do not match.`n`nDo you want to compare the objects anyway?", "Compare", "YesNo", "Warning")) -ne "Yes") { return } } $compareResult = Compare-PolicyObjects @($script:compareSource, $filePolicy) $script:dgCompareInfo.ItemsSource = $compareResult Update-CompareGridFilter Update-CompareSummary $tabCtrl = $script:compareForm.FindName("tabCompare") if($tabCtrl) { $tabCtrl.SelectedIndex = 1 } Write-Status "" } function Update-CompareSummary { if(-not $script:txtCompareSummary -or -not $script:dgCompareInfo) { return } $items = @($script:dgCompareInfo.ItemsSource) $total = $items.Count $mismatches = @($items | Where-Object { $_.Match -eq $false }).Count $skipped = @($items | Where-Object { $null -eq $_.Match }).Count $matched = $total - $mismatches - $skipped $script:txtCompareSummary.Text = "$total properties - $matched matched, $mismatches mismatched$(if($skipped -gt 0) { ", $skipped ignored" } else { '' })" } function Update-CompareGridFilter { if(-not $script:dgCompareInfo -or -not $script:dgCompareInfo.Items) { return } $mode = if($script:cbCompareFilter -and $script:cbCompareFilter.SelectedItem) { [string]$script:cbCompareFilter.SelectedItem.Tag } else { "All" } switch($mode) { "Mismatch" { $script:dgCompareInfo.Items.Filter = [Predicate[object]]{ param($item) $item.Match -eq $false } } "Match" { $script:dgCompareInfo.Items.Filter = [Predicate[object]]{ param($item) $item.Match -eq $true } } default { $script:dgCompareInfo.Items.Filter = $null } } } # ─── Provider Options ───────────────────────────────────────────────────────── function Set-CompareProviderOptions { param($Control) $Provider = $Control.SelectedItem $providerPanel = $null if($Provider -and $Provider.OptionsXaml) { # The cached panels are keyed by tenant so a tenant switch rebuilds them. # No current provider caches tenant data on its panel; the invalidation # stays as the only guard should a future one do so. if($script:CompareProviderOptionsCache -isnot [Hashtable] -or $script:CompareProviderOptionsCacheTenant -ne $script:OrganizationId) { $script:CompareProviderOptionsCache = @{} $script:CompareProviderOptionsCacheTenant = $script:OrganizationId } if($script:CompareProviderOptionsCache.ContainsKey($Provider.Value)) { $providerPanel = $script:CompareProviderOptionsCache[$Provider.Value] } else { $providerPanel = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\$($Provider.OptionsXaml).xaml")) if($providerPanel) { $script:CompareProviderOptionsCache[$Provider.Value] = $providerPanel Register-CompareProviderBrowseEvents $providerPanel $Provider } else { Write-Log "Failed to load provider options for '$($Provider.Name)'" 3 } } if($providerPanel) { $providerPanel.DataContext = $Provider } $script:ccContentProviderOptions.Content = $providerPanel } else { $script:ccContentProviderOptions.Content = $null } $script:ccContentProviderOptions.Visibility = if($null -eq $script:ccContentProviderOptions.Content) { "Collapsed" } else { "Visible" } Update-BulkCompareFormForProvider $Provider } function Register-CompareProviderBrowseEvents { param($Panel, $Provider) $browseExportPath = $Panel.FindName("browseExportPath") if($browseExportPath) { $browseExportPath.Tag = @{ Provider = $Provider; Panel = $Panel } $browseExportPath.Add_Click({ $tag = $this.Tag $folder = $script:UIProvider.ShowFolderPicker($tag.Provider.ExportPath, "Select root folder for compare") if($folder) { $tag.Provider.ExportPath = $folder $dc = $tag.Panel.DataContext; $tag.Panel.DataContext = $null; $tag.Panel.DataContext = $dc } }) } $browseSavePath = $Panel.FindName("browseSavePath") if($browseSavePath) { $browseSavePath.Tag = @{ Provider = $Provider; Panel = $Panel } $browseSavePath.Add_Click({ $tag = $this.Tag $folder = $script:UIProvider.ShowFolderPicker($tag.Provider.SavePath, "Select save folder") if($folder) { $tag.Provider.SavePath = $folder $dc = $tag.Panel.DataContext; $tag.Panel.DataContext = $null; $tag.Panel.DataContext = $dc } }) } $browseSource = $Panel.FindName("browseExportPathSource") if($browseSource) { $browseSource.Tag = @{ Provider = $Provider; Panel = $Panel } $browseSource.Add_Click({ $tag = $this.Tag $folder = $script:UIProvider.ShowFolderPicker($tag.Provider.SourcePath, "Select source root folder") if($folder) { $tag.Provider.SourcePath = $folder $dc = $tag.Panel.DataContext; $tag.Panel.DataContext = $null; $tag.Panel.DataContext = $dc } }) } $browseCompare = $Panel.FindName("browseExportPathCompare") if($browseCompare) { $browseCompare.Tag = @{ Provider = $Provider; Panel = $Panel } $browseCompare.Add_Click({ $tag = $this.Tag $folder = $script:UIProvider.ShowFolderPicker($tag.Provider.ComparePath, "Select compare root folder") if($folder) { $tag.Provider.ComparePath = $folder $dc = $tag.Panel.DataContext; $tag.Panel.DataContext = $null; $tag.Panel.DataContext = $dc } }) } }