Files

431 lines
22 KiB
PowerShell

# Avalonia port of the Bulk Compare dialog from
# UI/WPF/Extensions/CompareUI.ps1 (Show-GraphBulkCompareForm + helpers).
# New file because Bulk Compare is a self-contained subsystem and the WPF
# original sits in CompareUI.ps1 alongside the single-policy compare; we
# already split that into Extensions/IntuneManagerCompareUI.ps1 (architecture
# rule R9, R12).
#
# Slice 4f: full Bulk Compare form for the four "list-driven" providers
# (export / IntuneWithExport / name / exportedFolders). A fifth, Direct
# policy-pair provider was removed in 2026-09 - a two-object compare belongs
# in the single Compare form, which already picks a second policy.
#
# Helpers used (all in Internal/Compare.ps1, loaded in both UI backends):
# Set-CompareRuntimeOptions, Get-BulkCompareOutputProvider,
# Get-CompareOutputType, Start-BulkCompare.
# Provider classes (Classes/CompareClasses.ps1) inherit CompareProviderBase
# and have real CLR `[string]Name` / `[string]Value` properties + Validate /
# SaveSettings / GetComparePairs methods, so they bind directly to control
# DataContext for the per-provider option panels' TwoWay TextBox bindings.
function Show-GraphBulkCompareForm
{
$ui = $script:UIProvider
$form = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/BulkCompareForm.axaml'))
if (-not $form) { return }
$hostType = Get-AvaloniaHost
$cbProvider = $hostType::FindByName($form, 'cbCompareProvider')
$ccProviderOpts = $hostType::FindByName($form, 'ccContentProviderOptions')
$cbSave = $hostType::FindByName($form, 'cbCompareSave')
$cbOutFmt = $hostType::FindByName($form, 'cbCompareOutputFormat')
$cbType = $hostType::FindByName($form, 'cbCompareType')
$cbDelimiter = $hostType::FindByName($form, 'cbCompareCSVDelimiter')
$cbMultiValueDelim = $hostType::FindByName($form, 'cbCompareMultiValueDelimiter')
$chkIgnoreCore = $hostType::FindByName($form, 'chkIgnoreCoreProperties')
$chkSkipAssignments = $hostType::FindByName($form, 'chkSkipCompareAssignments')
$chkSkipMissingSrc = $hostType::FindByName($form, 'chkSkipMissingSourcePolicies')
$chkSkipMissingDst = $hostType::FindByName($form, 'chkSkipMissingDestinationPolicies')
$grdObjects = $hostType::FindByName($form, 'grdObjectsToCompareSection')
$dgObjects = $hostType::FindByName($form, 'dgObjectsToCompare')
$txtStatus = $hostType::FindByName($form, 'txtBulkCompareStatus')
$btnStart = $hostType::FindByName($form, 'btnStartCompare')
$btnClose = $hostType::FindByName($form, 'btnClose')
# Panel cache + per-provider Value->instance maps + control refs. Event
# handlers reach ALL of this through $script:_bulkCompareFormState -
# closure captures of function locals do NOT survive
# ConvertTo-AvaloniaEventScriptBlock (NewBoundScriptBlock rebinds to the
# module and drops them; only global/module variables fall through).
$state = [ordered]@{
ProviderPanelCache = @{}
ProviderMap = @{}
SaveMap = @{}
OutputProviderMap = @{}
TypeMap = @{}
Rows = @()
Ui = $ui
HostType = $hostType
CbProvider = $cbProvider
CcProviderOpts = $ccProviderOpts
CbSave = $cbSave
CbOutFmt = $cbOutFmt
CbType = $cbType
CbDelimiter = $cbDelimiter
CbMultiValueDelim = $cbMultiValueDelim
ChkIgnoreCore = $chkIgnoreCore
ChkSkipAssignments = $chkSkipAssignments
ChkSkipMissingSrc = $chkSkipMissingSrc
ChkSkipMissingDst = $chkSkipMissingDst
GrdObjects = $grdObjects
DgObjects = $dgObjects
TxtStatus = $txtStatus
BtnStart = $btnStart
BtnClose = $btnClose
}
$script:_bulkCompareFormState = $state
# --- Combo: Provider ---------------------------------------------------------
if ($cbProvider) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($p in @($script:compareProviders)) {
if (-not $p) { continue }
$items.Add([SettingsListItem]@{ Name = [string]$p.Name; Value = [string]$p.Value }) | Out-Null
$state.ProviderMap[[string]$p.Value] = $p
}
$cbProvider.ItemsSource = $items
$defaultProvider = (Get-SettingStoreValue "Compare" "Provider" "export")
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultProvider" } | Select-Object -First 1
if (-not $sel -and $items.Count -gt 0) { $sel = $items[0] }
if ($sel) { $cbProvider.SelectedItem = $sel }
}
# --- Combo: Save (one-file-per-type vs all) ---------------------------------
if ($cbSave) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($t in @($script:compareOutputTypes)) {
if (-not $t) { continue }
$items.Add([SettingsListItem]@{ Name = [string]$t.Name; Value = [string]$t.Value }) | Out-Null
$state.SaveMap[[string]$t.Value] = $t
}
$cbSave.ItemsSource = $items
$defaultSave = (Get-SettingStoreValue "Compare" "SaveType" "objectType")
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultSave" } | Select-Object -First 1
if (-not $sel -and $items.Count -gt 0) { $sel = $items[0] }
if ($sel) { $cbSave.SelectedItem = $sel }
}
# --- Combo: Output Format (CSV / JSON) --------------------------------------
if ($cbOutFmt) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($p in @($script:compareOutputProviders)) {
if (-not $p) { continue }
$items.Add([SettingsListItem]@{ Name = [string]$p.Name; Value = [string]$p.Value }) | Out-Null
$state.OutputProviderMap[[string]$p.Value] = $p
}
$cbOutFmt.ItemsSource = $items
$defaultOut = (Get-SettingStoreValue "Compare" "OutputFormat" "csv")
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultOut" } | Select-Object -First 1
if (-not $sel -and $items.Count -gt 0) { $sel = $items[0] }
if ($sel) { $cbOutFmt.SelectedItem = $sel }
}
# --- Combo: Comparison Type --------------------------------------------------
if ($cbType) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($t in @($script:comparisonTypes)) {
if (-not $t) { continue }
$items.Add([SettingsListItem]@{ Name = [string]$t.Name; Value = [string]$t.Value }) | Out-Null
$state.TypeMap[[string]$t.Value] = $t
}
$cbType.ItemsSource = $items
$defaultType = (Get-SettingStoreValue "Compare" "Type" "property")
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultType" } | Select-Object -First 1
if (-not $sel -and $items.Count -gt 0) { $sel = $items[0] }
if ($sel) { $cbType.SelectedItem = $sel }
}
# --- Combo: CSV Delimiter ----------------------------------------------------
if ($cbDelimiter) {
$cbDelimiter.ItemsSource = @("", ",", ";", "-", "|")
$defaultDelim = (Get-SettingStoreValue "Compare" "Delimiter" ";")
$cbDelimiter.SelectedItem = $defaultDelim
}
# --- Combo: Multi-Value Delimiter (doc-compare ObjectSeparator) ---------------
if ($cbMultiValueDelim) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
$items.Add([SettingsListItem]@{ Name = 'New line'; Value = [Environment]::NewLine }) | Out-Null
$items.Add([SettingsListItem]@{ Name = ';'; Value = ';' }) | Out-Null
$items.Add([SettingsListItem]@{ Name = '|'; Value = '|' }) | Out-Null
$cbMultiValueDelim.ItemsSource = $items
$defaultSep = (Get-SettingStoreValue "Compare" "ObjectSeparator" ([Environment]::NewLine))
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultSep" } | Select-Object -First 1
if (-not $sel) { $sel = $items[0] }
$cbMultiValueDelim.SelectedItem = $sel
}
# --- Checkboxes ----------------------------------------------------------------
if ($chkIgnoreCore) {
$chkIgnoreCore.IsChecked = ((Get-SettingStoreValue "Compare" "IgnoreCoreProperties" "true") -eq "true")
}
if ($chkSkipAssignments) {
$chkSkipAssignments.IsChecked = ((Get-SettingStoreValue "Compare" "SkipCompareAssignments" "false") -eq "true")
}
if ($chkSkipMissingSrc) {
$chkSkipMissingSrc.IsChecked = ((Get-SettingStoreValue "Compare" "SkipMissingSourcePolicies" "false") -eq "true")
}
if ($chkSkipMissingDst) {
$chkSkipMissingDst.IsChecked = ((Get-SettingStoreValue "Compare" "SkipMissingDestinationPolicies" "false") -eq "true")
}
# --- Objects-to-compare grid -------------------------------------------------
$rows = New-Object 'System.Collections.Generic.List[BulkCompareRowItem]'
foreach ($intuneGroup in @($script:IntuneGroups)) {
if (-not $intuneGroup -or -not $intuneGroup.Title) { continue }
$rows.Add([BulkCompareRowItem]@{
Title = [string]$intuneGroup.Title
Selected = $true
ObjectGroup = $intuneGroup
}) | Out-Null
}
$state.Rows = @($rows)
if ($dgObjects) { $dgObjects.ItemsSource = $state.Rows }
# Select/deselect-all moved into the DataGrid column header.
Initialize-AvaloniaGridSelectAllHeader -Grid $dgObjects -BindingProperty 'Selected' -InitiallyChecked $true | Out-Null
# --- Provider panel swap -----------------------------------------------------
$script:_bulkCompareApplyProvider = {
param($Provider)
$st = $script:_bulkCompareFormState
if (-not $st -or -not $st.CcProviderOpts) { return }
if (-not $Provider) {
$st.CcProviderOpts.Content = $null
$st.CcProviderOpts.IsVisible = $false
if ($st.GrdObjects) { $st.GrdObjects.IsVisible = $true }
return
}
$panel = $null
if ($Provider.OptionsXaml) {
if ($st.ProviderPanelCache.ContainsKey($Provider.Value)) {
$panel = $st.ProviderPanelCache[$Provider.Value]
} else {
$panel = $st.Ui.GetXamlObject((Join-Path $script:AppUIRootFolder "XAML/$($Provider.OptionsXaml).axaml"))
if ($panel) {
$st.ProviderPanelCache[$Provider.Value] = $panel
Register-BulkCompareProviderBrowseEvents -Panel $panel -Provider $Provider -HostType $st.HostType
}
}
if ($panel) { $panel.DataContext = $Provider }
}
$st.CcProviderOpts.Content = $panel
$st.CcProviderOpts.IsVisible = ($null -ne $panel)
if ($st.GrdObjects) {
$st.GrdObjects.IsVisible = -not [bool]$Provider.IgnoreGroups
}
}
if ($cbProvider) {
$cbProvider.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_bulkCompareFormState
if (-not $st) { return }
$sel = $s.SelectedItem
$provider = if ($sel) { $st.ProviderMap[[string]$sel.Value] } else { $null }
& $script:_bulkCompareApplyProvider $provider
}))
# Initial render.
$initial = $null
if ($cbProvider.SelectedItem) {
$initial = $state.ProviderMap[[string]$cbProvider.SelectedItem.Value]
}
& $script:_bulkCompareApplyProvider $initial
}
# --- Output format → CSV-delimiter enable toggle ----------------------------
$script:_bulkCompareApplyDelimiterEnable = {
$st = $script:_bulkCompareFormState
if (-not $st -or -not $st.CbDelimiter -or -not $st.CbOutFmt -or -not $st.CbOutFmt.SelectedItem) { return }
$sel = $st.OutputProviderMap[[string]$st.CbOutFmt.SelectedItem.Value]
$st.CbDelimiter.IsEnabled = ($sel -is [CompareCSVOutputProvider])
}
if ($cbOutFmt) {
$cbOutFmt.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock { param($s, $e) & $script:_bulkCompareApplyDelimiterEnable }))
& $script:_bulkCompareApplyDelimiterEnable
}
# --- Buttons -----------------------------------------------------------------
if ($btnClose) {
$btnClose.add_Click((ConvertTo-AvaloniaEventScriptBlock {
Show-ModalObject
$script:_bulkCompareFormState = $null
}))
}
if ($btnStart) {
$btnStart.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_bulkCompareFormState
if (-not $st) { return }
$providerSel = if ($st.CbProvider) { $st.CbProvider.SelectedItem } else { $null }
$provider = if ($providerSel) { $st.ProviderMap[[string]$providerSel.Value] } else { $null }
if (-not $provider) {
$st.Ui.ShowMessageBox("No compare provider selected.", "Compare", "OK", "Error")
return
}
# Persist all five user choices (matches WPF order).
if ($st.CbProvider -and $st.CbProvider.SelectedItem) { Save-SettingStoreValue "Compare" "Provider" $st.CbProvider.SelectedItem.Value }
if ($st.CbType -and $st.CbType.SelectedItem) { Save-SettingStoreValue "Compare" "Type" $st.CbType.SelectedItem.Value }
if ($st.CbDelimiter) { Save-SettingStoreValue "Compare" "Delimiter" ([string]$st.CbDelimiter.SelectedItem) }
if ($st.CbOutFmt -and $st.CbOutFmt.SelectedItem) { Save-SettingStoreValue "Compare" "OutputFormat" $st.CbOutFmt.SelectedItem.Value }
if ($st.CbSave -and $st.CbSave.SelectedItem) { Save-SettingStoreValue "Compare" "SaveType" $st.CbSave.SelectedItem.Value }
if ($st.CbMultiValueDelim -and $st.CbMultiValueDelim.SelectedItem) { Save-SettingStoreValue "Compare" "ObjectSeparator" ([string]$st.CbMultiValueDelim.SelectedItem.Value) }
if ($st.ChkIgnoreCore) { Save-SettingStoreValue "Compare" "IgnoreCoreProperties" $(if ($st.ChkIgnoreCore.IsChecked) { "true" } else { "false" }) }
if ($st.ChkSkipAssignments) { Save-SettingStoreValue "Compare" "SkipCompareAssignments" $(if ($st.ChkSkipAssignments.IsChecked) { "true" } else { "false" }) }
if ($st.ChkSkipMissingSrc) { Save-SettingStoreValue "Compare" "SkipMissingSourcePolicies" $(if ($st.ChkSkipMissingSrc.IsChecked) { "true" } else { "false" }) }
if ($st.ChkSkipMissingDst) { Save-SettingStoreValue "Compare" "SkipMissingDestinationPolicies" $(if ($st.ChkSkipMissingDst.IsChecked) { "true" } else { "false" }) }
if ($st.ChkSkipMissingSrc) { $provider.SkipMissingSourcePolicies = [bool]$st.ChkSkipMissingSrc.IsChecked }
if ($st.ChkSkipMissingDst) { $provider.SkipMissingDestinationPolicies = [bool]$st.ChkSkipMissingDst.IsChecked }
if ($st.BtnStart) { $st.BtnStart.IsEnabled = $false }
if ($st.BtnClose) { $st.BtnClose.IsEnabled = $false }
if ($st.TxtStatus) { $st.TxtStatus.Text = "Comparing..." }
Write-Status "Compare objects"
try {
# Push runtime options into Internal/Compare.ps1 — mirrors WPF
# Set-CompareRuntimeOptionsFromUI.
$runtimeArgs = @{}
if ($st.CbType -and $st.CbType.SelectedItem) {
$runtimeArgs.CompareType = [string]$st.CbType.SelectedItem.Value
$cd = $st.TypeMap[[string]$st.CbType.SelectedItem.Value]
if ($cd) { $runtimeArgs.CompareDefinition = $cd }
}
if ($st.CbOutFmt -and $st.CbOutFmt.SelectedItem) {
$op = $st.OutputProviderMap[[string]$st.CbOutFmt.SelectedItem.Value]
if ($op) { $runtimeArgs.OutputProvider = $op }
}
if ($st.CbSave -and $st.CbSave.SelectedItem) {
$runtimeArgs.SaveType = [string]$st.CbSave.SelectedItem.Value
}
if ($st.CbDelimiter -and $null -ne $st.CbDelimiter.SelectedItem) {
$runtimeArgs.CsvDelimiter = [string]$st.CbDelimiter.SelectedItem
}
if ($st.CbMultiValueDelim -and $st.CbMultiValueDelim.SelectedItem) {
$runtimeArgs.ObjectSeparator = [string]$st.CbMultiValueDelim.SelectedItem.Value
}
if ($st.ChkIgnoreCore) {
$runtimeArgs.IgnoreCoreProperties = [bool]$st.ChkIgnoreCore.IsChecked
}
if ($st.ChkSkipAssignments) {
$runtimeArgs.SkipAssignments = [bool]$st.ChkSkipAssignments.IsChecked
}
Set-CompareRuntimeOptions @runtimeArgs
$selectedGroups = @()
if (-not $provider.IgnoreGroups) {
$selectedGroups = @($st.Rows |
Where-Object { $_ -and $_.Selected -and $_.ObjectGroup } |
ForEach-Object { $_.ObjectGroup })
if ($selectedGroups.Count -eq 0) {
$st.Ui.ShowMessageBox("No object types selected.", "Compare", "OK", "Warning")
return
}
}
Start-BulkCompare $provider -SelectedGroups $selectedGroups
if ($st.TxtStatus) { $st.TxtStatus.Text = "Compare finished." }
} catch {
Write-LogError "Bulk compare failed" $_.Exception
$st.Ui.ShowMessageBox("Compare failed: $($_.Exception.Message)", "Compare", "OK", "Error")
if ($st.TxtStatus) { $st.TxtStatus.Text = "Compare failed." }
} finally {
Write-Status ""
if ($st.BtnStart) { $st.BtnStart.IsEnabled = $true }
if ($st.BtnClose) { $st.BtnClose.IsEnabled = $true }
}
}))
}
$form.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
Show-ModalObject
$e.Handled = $true
}
}.GetNewClosure()))
$ui.ShowModalForm("Bulk Compare Objects", $form, $true)
}
function Register-BulkCompareProviderBrowseEvents
{
# Wires the four Browse buttons that may appear in the per-provider option
# panels (browseExportPath / browseSavePath / browseExportPathSource /
# browseExportPathCompare). Handler context rides on each button's Tag
# (sender-based) - function locals are not available at click time
# (ConvertTo-AvaloniaEventScriptBlock strips closures).
# NOTE: param() must stay the FIRST statement - a $ui assignment above it
# once made 'param' unparseable at call time and broke every panel swap.
param($Panel, $Provider, $HostType)
if (-not $Panel -or -not $Provider) { return }
# Text boxes -> provider properties. The panels' XAML has no bindings and
# nothing synced them, so everything the user typed was discarded - which
# made the "Named Objects in Intune" provider (whose only inputs are two
# text boxes) impossible to use at all. Each box pushes on TextChanged;
# the current provider value seeds the box up front.
$textPropertyPairs = @(
@{ Name = 'txtCompareNameFilter'; Property = 'NameFilter' },
@{ Name = 'txtExportPath'; Property = 'ExportPath' },
@{ Name = 'txtExportPathSource'; Property = 'SourcePath' },
@{ Name = 'txtExportPathCompare'; Property = 'ComparePath' },
@{ Name = 'txtCompareSource'; Property = 'SourcePattern' },
@{ Name = 'txtCompareWith'; Property = 'ComparePattern' },
@{ Name = 'txtSavePath'; Property = 'SavePath' }
)
foreach ($pair in $textPropertyPairs) {
$tb = $HostType::FindByName($Panel, $pair.Name)
if (-not $tb) { continue }
if (-not $Provider.PSObject.Properties[$pair.Property]) { continue }
$tb.Text = [string]$Provider.($pair.Property)
$tb.Tag = @{ Provider = $Provider; Property = $pair.Property }
$tb.add_TextChanged((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
$tag = $S.Tag
if ($tag -and $tag.Provider) { $tag.Provider.($tag.Property) = [string]$S.Text }
}))
}
$browsePathPairs = @(
@{ Name = 'browseExportPath'; Property = 'ExportPath'; TextName = 'txtExportPath'; Title = 'Select root folder for compare' },
@{ Name = 'browseSavePath'; Property = 'SavePath'; TextName = 'txtSavePath'; Title = 'Select save folder' },
@{ Name = 'browseExportPathSource'; Property = 'SourcePath'; TextName = 'txtExportPathSource'; Title = 'Select source root folder' },
@{ Name = 'browseExportPathCompare'; Property = 'ComparePath'; TextName = 'txtExportPathCompare'; Title = 'Select compare root folder' }
)
foreach ($pair in $browsePathPairs) {
$btn = $HostType::FindByName($Panel, $pair.Name)
if (-not $btn) { continue }
# Provider classes don't expose every property; skip wiring for
# buttons whose target property doesn't exist on this provider.
if (-not $Provider.PSObject.Properties[$pair.Property]) { continue }
$btn.Tag = @{
Provider = $Provider; Property = $pair.Property; Title = $pair.Title
TextBox = $HostType::FindByName($Panel, $pair.TextName)
}
$btn.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$tag = $s.Tag
$current = if ($tag.TextBox) { [string]$tag.TextBox.Text } else { '' }
$folder = $script:UIProvider.ShowFolderPicker($current, [string]$tag.Title)
if ($folder) {
$tag.Provider.($tag.Property) = $folder
# Write the TextBox directly: these panels have no bindings, so
# the old DataContext null/restore refresh changed nothing on
# screen and picking a folder looked like it did nothing.
if ($tag.TextBox) { $tag.TextBox.Text = $folder }
}
}))
}
}