IntuneManagement 4.0.0-beta1

This commit is contained in:
Mikael Karlsson
2026-09-23 19:13:09 +10:00
commit 7869619510
892 changed files with 577109 additions and 0 deletions
@@ -0,0 +1,682 @@
# Avalonia port of the Compare dialog from UI/WPF/Extensions/CompareUI.ps1
# (Show-GraphCompareForm). New file because Compare is a self-contained
# subsystem we don't want to graft onto the WPF CompareUI.ps1 file
# (architecture rule R9, R12).
#
# Slice 3d1 scope:
# - Intune-vs-Intune compare path only. The user must check 2 rows in
# dgIntuneManagerObjects before clicking Compare; if 1 is checked, the
# "browse second Intune object" path is stubbed (3d2/3d3 will add the
# ObjectPicker sub-modal).
#
# Slice 3d2 adds:
# - File compare branch — CompareFileOptions.axaml mounted into
# ccCompareInputOptions; browseCompareObject calls
# (Get-AvaloniaHost)::OpenFilePicker. File compare path in
# btnStartCompare loads the file via $source.PolicyType.GetObject
# and passes the result to Compare-PolicyObjects. Mirrors WPF lines
# 475-525 of UI/WPF/Extensions/CompareUI.ps1.
#
# Slice 3d3 adds:
# - Match-based row coloring via DataGrid.LoadingRow + Row.Foreground swap
# (Avalonia has no DataTrigger; Foreground inheritance cascades into the
# TextBlock cells, matching the WPF DataGridCell.Foreground swap).
# - btnCompareSave wired through (Get-AvaloniaHost)::SaveFilePicker +
# Get-CompareOutputProviderByExtension + $provider.FormatRows pipeline.
# - btnCompareCopy wired through Get-CompareCsvInfo | Set-Clipboard.
#
# Still deferred:
# - Browse-pick second Intune object (ObjectPicker sub-modal) — later slice.
#
# Data layer notes:
# - $script:comparisonTypes / $script:compareOutputProviders are live views onto
# [CompareRegistry] (registered at module load in Internal/Compare.ps1; the
# "doc" type self-registers from Internal/Documentation.ps1). Not bindable
# in Avalonia (avalonia-binding-needs-clr-types), so we project each
# into [SettingsListItem] (Name + Value) for ComboBox display and
# keep a parallel hashtable that maps Value -> original PSCustomObject
# so we can hand the source object back to Set-CompareRuntimeOptions.
# - Compare-PolicyObjects returns PSCustomObject rows; we project those
# into [CompareResultRowItem] before assigning DataGrid.ItemsSource.
function Invoke-IntuneManagerCompare
{
# Mirror of the WPF btnCompare handler in UI/WPF/Extensions/IntuneManagerUI.ps1:
# prefer rows the user has check-boxed; fall back to the highlighted row.
if (-not $script:dgIntuneManagerObjects) { return }
# Scan the grid's CURRENT (filtered) ItemsSource so a checked row hidden
# by the filter is never compared; fall back to the highlighted row.
$selectedRows = @(@($script:dgIntuneManagerObjects.ItemsSource) | Where-Object { $_ -and $_.IsSelected })
if ($selectedRows.Count -eq 0 -and $script:dgIntuneManagerObjects.SelectedItem) {
$selectedRows = @($script:dgIntuneManagerObjects.SelectedItem)
}
if ($selectedRows.Count -eq 0) { return }
# Show-GraphCompareForm wants the underlying IntunePolicyBase objects,
# not the IntuneObjectRowItem wrappers.
$policies = @($selectedRows | ForEach-Object { $_.Source } | Where-Object { $_ })
if ($policies.Count -eq 0) {
Write-Log "Compare: $($selectedRows.Count) row(s) selected but none carried a policy object" 3
return
}
Show-GraphCompareForm $policies
}
function Show-GraphCompareForm
{
param([object[]]$Policies)
$ui = $script:UIProvider
if (-not $Policies -or $Policies.Count -eq 0) { return }
$compareForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CompareForm.axaml'))
if (-not $compareForm) { return }
$hostType = Get-AvaloniaHost
$tabCompare = $hostType::FindByName($compareForm, 'tabCompare')
$txtIntuneObject = $hostType::FindByName($compareForm, 'txtIntuneObject')
$cbCompareInput = $hostType::FindByName($compareForm, 'cbCompareInput')
$ccCompareInputOpts = $hostType::FindByName($compareForm, 'ccCompareInputOptions')
$cbCompareType = $hostType::FindByName($compareForm, 'cbCompareType')
$cbOutputFormat = $hostType::FindByName($compareForm, 'cbSingleCompareOutputFormat')
$chkIgnoreCore = $hostType::FindByName($compareForm, 'chkIgnoreCoreProperties')
$cbCompareFilter = $hostType::FindByName($compareForm, 'cbCompareFilter')
$cbMultiValueDelim = $hostType::FindByName($compareForm, 'cbCompareMultiValueDelimiter')
$chkSkipAssignments = $hostType::FindByName($compareForm, 'chkSkipCompareAssignments')
$txtSummary = $hostType::FindByName($compareForm, 'txtCompareSummary')
$dgCompareInfo = $hostType::FindByName($compareForm, 'dgCompareInfo')
$btnSwap = $hostType::FindByName($compareForm, 'btnSwap')
$btnStartCompare = $hostType::FindByName($compareForm, 'btnStartCompare')
$btnClose = $hostType::FindByName($compareForm, 'btnClose')
$btnCompareSave = $hostType::FindByName($compareForm, 'btnCompareSave')
$btnCompareCopy = $hostType::FindByName($compareForm, 'btnCompareCopy')
# State for this dialog instance. Lives in MODULE scope: handlers are
# re-bound by ConvertTo-AvaloniaEventScriptBlock, which strips
# function-local captures - the previous captured $state left Compare,
# Swap, Save, Copy and both browse handlers throwing on their first line.
$state = [ordered]@{
Source = $Policies[0]
Target = if ($Policies.Count -ge 2) { $Policies[1] } else { $null }
AllRows = @()
InputType = 'intune'
TypeMap = @{}
OutputMap = @{}
InputMap = @{}
IntunePanel = $null
IntunePanelTxt = $null
FilePanel = $null
FilePanelTxt = $null
HostType = $hostType
Form = $compareForm
TabCompare = $tabCompare
TxtIntuneObject = $txtIntuneObject
CcCompareInputOpts = $ccCompareInputOpts
CbCompareType = $cbCompareType
CbOutputFormat = $cbOutputFormat
CbCompareInput = $cbCompareInput
ChkIgnoreCore = $chkIgnoreCore
CbCompareFilter = $cbCompareFilter
CbMultiValueDelim = $cbMultiValueDelim
ChkSkipAssignments = $chkSkipAssignments
TxtSummary = $txtSummary
DgCompareInfo = $dgCompareInfo
}
$script:_compareFormState = $state
if ($txtIntuneObject) { $txtIntuneObject.Text = [string]$state.Source.Name }
# --- Match-based row coloring ------------------------------------------------
# Avalonia has no DataTrigger; emulate the WPF DataGridCell.Foreground swap
# by hooking LoadingRow and setting Row.Foreground based on the bound
# CompareResultRowItem.Match. Foreground cascades into TextBlock cells via
# property inheritance, matching the WPF Foreground={DynamicResource
# MismatchColor} effect. Match=$null (skipped/ignored) leaves the row at
# the default Foreground.
if ($dgCompareInfo) {
$dgCompareInfo.add_LoadingRow((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_compareFormState
if (-not $st) { return }
$row = $e.Row
$item = $row.DataContext
if ($null -eq $item) { return }
if ($item.Match -eq $false) {
$brush = $null
if ([Avalonia.Application]::Current -and [Avalonia.Application]::Current.Resources) {
[void][Avalonia.Application]::Current.TryGetResource('MismatchColor', $row.ActualThemeVariant, [ref]$brush)
}
if ($brush) { $row.Foreground = $brush }
} else {
$row.ClearValue([Avalonia.Controls.DataGridRow]::ForegroundProperty)
}
}))
}
# --- Combo: Comparison Type --------------------------------------------------
if ($cbCompareType) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($t in @($script:comparisonTypes)) {
$li = [SettingsListItem]@{ Name = [string]$t.Name; Value = [string]$t.Value }
$items.Add($li) | Out-Null
$state.TypeMap[[string]$t.Value] = $t
}
$cbCompareType.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) { $cbCompareType.SelectedItem = $sel }
}
# --- Combo: Output Format ----------------------------------------------------
if ($cbOutputFormat) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
foreach ($p in @($script:compareOutputProviders)) {
$li = [SettingsListItem]@{ Name = [string]$p.Name; Value = [string]$p.Value }
$items.Add($li) | Out-Null
$state.OutputMap[[string]$p.Value] = $p
}
$cbOutputFormat.ItemsSource = $items
$defaultOut = (Get-SettingStoreValue "Compare" "SingleOutputFormat" "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) { $cbOutputFormat.SelectedItem = $sel }
}
# --- Combo: Compare Input (file vs intune) ----------------------------------
if ($cbCompareInput) {
$items = New-Object 'System.Collections.Generic.List[SettingsListItem]'
$items.Add([SettingsListItem]@{ Name = 'File'; Value = 'file' }) | Out-Null
$items.Add([SettingsListItem]@{ Name = 'Intune Object'; Value = 'intune' }) | Out-Null
$state.InputMap['file'] = 'file'
$state.InputMap['intune'] = 'intune'
$cbCompareInput.ItemsSource = $items
# Default: prefer "intune" if a target is already populated;
# otherwise honour the saved setting.
$defaultInput = if ($state.Target) { 'intune' } else { (Get-SettingStoreValue "Compare" "InputType" "file") }
$sel = $items | Where-Object { "$($_.Value)" -eq "$defaultInput" } | Select-Object -First 1
if (-not $sel) { $sel = $items[1] } # 'intune'
if ($sel) {
$cbCompareInput.SelectedItem = $sel
$state.InputType = [string]$sel.Value
}
$cbCompareInput.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_compareFormState
if (-not $st) { return }
$val = if ($s.SelectedItem) { [string]$s.SelectedItem.Value } else { 'intune' }
$st.InputType = $val
Set-CompareInputPanel -State $st -HostType $st.HostType -Container $st.CcCompareInputOpts -Type $val
}))
# Initial panel render.
Set-CompareInputPanel -State $state -HostType $hostType -Container $ccCompareInputOpts -Type $state.InputType
}
# --- 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 ($cbCompareFilter) {
# 3-way result filter (All / Mismatch / Match). Mirror the other combos in
# this file (SettingsListItem + LoadXaml template) - Avalonia's binder needs
# CLR-typed items.
$filterItems = New-Object 'System.Collections.Generic.List[SettingsListItem]'
$filterItems.Add([SettingsListItem]@{ Name = 'All properties'; Value = 'All' }) | Out-Null
$filterItems.Add([SettingsListItem]@{ Name = 'Mismatches only'; Value = 'Mismatch' }) | Out-Null
$filterItems.Add([SettingsListItem]@{ Name = 'Matches only'; Value = 'Match' }) | Out-Null
$cbCompareFilter.ItemsSource = $filterItems
$savedFilter = Get-SettingStoreValue "Compare" "ResultFilter" "All"
$sel = $filterItems | Where-Object { "$($_.Value)" -eq "$savedFilter" } | Select-Object -First 1
if (-not $sel) { $sel = $filterItems[0] }
$cbCompareFilter.SelectedItem = $sel
$cbCompareFilter.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_compareFormState
if (-not $st) { return }
$mode = if ($s.SelectedItem) { [string]$s.SelectedItem.Value } else { 'All' }
Update-CompareGridRows -State $st -Grid $st.DgCompareInfo -Filter $mode
}))
}
# --- Buttons -----------------------------------------------------------------
if ($btnClose) {
$btnClose.add_Click((ConvertTo-AvaloniaEventScriptBlock {
Show-ModalObject
# Holds both hydrated policies plus every compare row - release it.
$script:_compareFormState = $null
}))
}
if ($btnSwap) {
$btnSwap.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
if (-not $st.Source -or -not $st.Target) { return }
$tmp = $st.Source
$st.Source = $st.Target
$st.Target = $tmp
if ($st.TxtIntuneObject) { $st.TxtIntuneObject.Text = [string]$st.Source.Name }
if ($st.IntunePanelTxt) { $st.IntunePanelTxt.Text = [string]$st.Target.Name }
}))
}
if ($btnStartCompare) {
$btnStartCompare.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
$inputType = $st.InputType
# Resolve the second object early so we fail fast with a useful
# message before persisting settings.
$secondPolicy = $null
if ($inputType -eq 'file') {
$compareFile = if ($st.FilePanelTxt) { [string]$st.FilePanelTxt.Text } else { '' }
if ([string]::IsNullOrWhiteSpace($compareFile)) {
$ui.ShowMessageBox("No file selected for comparison.", "Compare", "OK", "Error")
return
}
if (-not [IO.File]::Exists($compareFile)) {
$ui.ShowMessageBox("File '$compareFile' not found.", "Compare", "OK", "Error")
return
}
if (-not $st.Source -or -not $st.Source.PolicyType) {
$ui.ShowMessageBox("Source object has no PolicyType - cannot load file.", "Compare", "OK", "Error")
return
}
try {
$secondPolicy = $st.Source.PolicyType.GetObject([IO.FileInfo]$compareFile)
} catch {
Write-LogError "Failed to load compare file '$compareFile'" $_.Exception
$ui.ShowMessageBox("Failed to load file '$compareFile': $($_.Exception.Message)", "Compare", "OK", "Error")
return
}
if (-not $secondPolicy) {
$ui.ShowMessageBox("Failed to load file '$compareFile'. Object type may not match.", "Compare", "OK", "Error")
return
}
Save-SettingStoreValue "Compare" "LastFile" $compareFile
} else {
if (-not $st.Target) {
$ui.ShowMessageBox("No second Intune object selected. Pre-select two objects in the list (check the boxes) before opening Compare. Browse-pick is not yet ported.", "Compare", "OK", "Error")
return
}
$secondPolicy = $st.Target
}
# Persist user choices.
if ($st.CbCompareType -and $st.CbCompareType.SelectedItem) {
Save-SettingStoreValue "Compare" "Type" $st.CbCompareType.SelectedItem.Value
}
if ($st.CbCompareInput -and $st.CbCompareInput.SelectedItem) {
Save-SettingStoreValue "Compare" "InputType" $st.CbCompareInput.SelectedItem.Value
}
if ($st.CbOutputFormat -and $st.CbOutputFormat.SelectedItem) {
Save-SettingStoreValue "Compare" "SingleOutputFormat" $st.CbOutputFormat.SelectedItem.Value
}
if ($st.ChkIgnoreCore) {
Save-SettingStoreValue "Compare" "IgnoreCoreProperties" $(if ($st.ChkIgnoreCore.IsChecked) { "true" } else { "false" })
}
if ($st.CbCompareFilter -and $st.CbCompareFilter.SelectedItem) {
Save-SettingStoreValue "Compare" "ResultFilter" ([string]$st.CbCompareFilter.SelectedItem.Value)
}
if ($st.CbMultiValueDelim -and $st.CbMultiValueDelim.SelectedItem) {
Save-SettingStoreValue "Compare" "ObjectSeparator" ([string]$st.CbMultiValueDelim.SelectedItem.Value)
}
if ($st.ChkSkipAssignments) {
Save-SettingStoreValue "Compare" "SkipCompareAssignments" $(if ($st.ChkSkipAssignments.IsChecked) { "true" } else { "false" })
}
# Push runtime options into the Internal/Compare.ps1 module so the
# comparison engine sees the same values WPF would have set via
# Set-CompareRuntimeOptionsFromUI.
$runtimeArgs = @{}
if ($st.CbCompareType -and $st.CbCompareType.SelectedItem) {
$runtimeArgs.CompareType = [string]$st.CbCompareType.SelectedItem.Value
$cd = $st.TypeMap[[string]$st.CbCompareType.SelectedItem.Value]
if ($cd) { $runtimeArgs.CompareDefinition = $cd }
}
if ($st.ChkIgnoreCore) {
$runtimeArgs.IgnoreCoreProperties = [bool]$st.ChkIgnoreCore.IsChecked
}
if ($st.CbOutputFormat -and $st.CbOutputFormat.SelectedItem) {
$op = $st.OutputMap[[string]$st.CbOutputFormat.SelectedItem.Value]
if ($op) { $runtimeArgs.OutputProvider = $op }
}
if ($st.CbMultiValueDelim -and $st.CbMultiValueDelim.SelectedItem) {
$runtimeArgs.ObjectSeparator = [string]$st.CbMultiValueDelim.SelectedItem.Value
}
if ($st.ChkSkipAssignments) {
$runtimeArgs.SkipAssignments = [bool]$st.ChkSkipAssignments.IsChecked
}
Set-CompareRuntimeOptions @runtimeArgs
Write-Status "Compare objects"
try {
Resolve-FullPolicyForCompare $st.Source
if ($inputType -eq 'intune') {
Resolve-FullPolicyForCompare $secondPolicy
} else {
# File-loaded policy: warn if @odata.type doesn't match.
$sourceType = $null; $fileType = $null
try { $sourceType = $st.Source.Object.'@OData.Type' } catch { }
try { $fileType = $secondPolicy.Object.'@OData.Type' } catch { }
if ($sourceType -and $fileType -and $sourceType -ne $fileType) {
$answer = $ui.ShowMessageBox("The object types do not match.`n`nDo you want to compare the objects anyway?", "Compare", "YesNo", "Warning")
if ($answer -ne "Yes") { Write-Status ""; return }
}
}
$rawRows = @(Compare-PolicyObjects @($st.Source, $secondPolicy))
} catch {
Write-LogError "Compare-PolicyObjects failed" $_.Exception
$ui.ShowMessageBox("Compare failed: $($_.Exception.Message)", "Error", "OK", "Error")
Write-Status ""
return
}
$st.AllRows = @($rawRows | ForEach-Object {
# Tri-state Match glyph, mirroring the WPF CompareForm.xaml column:
# checkmark = match, ballot-X = mismatch, em-dash = ignored ($null).
# Built from [char] codes so the source stays ASCII (see
# [[ascii-only-string-literals]]) - the literal glyph exists only in
# the rendered string, never in the .ps1 bytes. Mismatch rows are
# already tinted red by the LoadingRow handler, which cascades to
# this cell too (matching the red WPF ballot-X).
$glyph = [char]0x2014
if ($_.Match -eq $true) { $glyph = [char]0x2713 }
elseif ($_.Match -eq $false) { $glyph = [char]0x2717 }
[CompareResultRowItem]@{
PropertyName = [string]$_.PropertyName
Category = [string]$_.Category
SubCategory = [string]$_.SubCategory
Object1Value = [string]$_.Object1Value
Object2Value = [string]$_.Object2Value
Match = $_.Match
MatchGlyph = [string]$glyph
}
})
$mode = if ($st.CbCompareFilter -and $st.CbCompareFilter.SelectedItem) { [string]$st.CbCompareFilter.SelectedItem.Value } else { 'All' }
Update-CompareGridRows -State $st -Grid $st.DgCompareInfo -Filter $mode
Update-CompareSummaryText -State $st -TextBlock $st.TxtSummary
if ($st.TabCompare) { $st.TabCompare.SelectedIndex = 1 }
Write-Status ""
}))
}
if ($btnCompareSave) {
$btnCompareSave.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
if (-not $st.AllRows -or $st.AllRows.Count -eq 0) { return }
# Pick output provider from the SelectedItem (combobox model has the
# underlying CompareOutputProviderBase via OutputMap), with CSV fallback.
$outProv = $null
if ($st.CbOutputFormat -and $st.CbOutputFormat.SelectedItem) {
$outProv = $st.OutputMap[[string]$st.CbOutputFormat.SelectedItem.Value]
}
if (-not $outProv) { $outProv = [CompareCSVOutputProvider]::new() }
$defaultExt = if ($outProv.Extension) { [string]$outProv.Extension } else { 'csv' }
$filterName = if ($defaultExt -eq 'json') { 'JSON files' } else { 'CSV files' }
$filterPattern = "*.$defaultExt"
$startDir = Get-SettingStoreValue "Compare" "LastSaveDirectory"
if (-not $startDir) { $startDir = Get-SettingValue "RootFolder" }
$picked = (Get-AvaloniaHost)::SaveFilePicker(
$script:Window,
'Save compare results',
[string]$st.Source.Name,
$defaultExt,
$filterName,
$filterPattern,
[string]$startDir)
if (-not $picked) { return }
try {
Save-SettingStoreValue "Compare" "LastSaveDirectory" ([IO.FileInfo]$picked).DirectoryName
$ext = [IO.Path]::GetExtension($picked).TrimStart('.')
$writeProv = Get-CompareOutputProviderByExtension $ext
if (-not $writeProv) { $writeProv = $outProv }
# FormatRows wants the Compare-PolicyObjects shape (PSCustomObject
# with PropertyName/Object1Value/...); CompareResultRowItem mirrors
# those property names so duck-typing through Get-Member works.
$propsArgs = @{}
if ($st.CbCompareType -and $st.CbCompareType.SelectedItem) {
$propsArgs.CompareType = [string]$st.CbCompareType.SelectedItem.Value
$propsArgs.CompareDefinition = $st.TypeMap[[string]$st.CbCompareType.SelectedItem.Value]
}
$props = Get-CompareOutputProps @propsArgs
$writeProv.FormatRows($st.AllRows, $props) | Out-File -LiteralPath $picked -Force -Encoding UTF8
} catch {
Write-LogError "Save compare results failed" $_.Exception
$ui.ShowMessageBox("Save failed: $($_.Exception.Message)", "Error", "OK", "Error")
}
}))
}
if ($btnCompareCopy) {
$btnCompareCopy.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
if (-not $st.AllRows -or $st.AllRows.Count -eq 0) { return }
try {
$compareType = if ($st.CbCompareType -and $st.CbCompareType.SelectedItem) {
[string]$st.CbCompareType.SelectedItem.Value
} else { 'property' }
$compareDef = $st.TypeMap[$compareType]
(Get-CompareCsvInfo $st.AllRows $st.Source $compareType $compareDef) | Set-Clipboard
} catch {
Write-LogError "Copy compare results failed" $_.Exception
$ui.ShowMessageBox("Copy failed: $($_.Exception.Message)", "Error", "OK", "Error")
}
}))
}
$compareForm.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
param($s, $e)
$st = $script:_compareFormState
if (-not $st) { return }
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
Show-ModalObject
$e.Handled = $true
}
}))
$ui.ShowModalForm("Compare Intune Objects", $compareForm, $true)
}
function Set-CompareInputPanel
{
param($State, $HostType, $Container, [string]$Type)
$ui = $script:UIProvider
if (-not $Container) { return }
if ($Type -eq 'intune') {
if (-not $State.IntunePanel) {
$State.IntunePanel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CompareIntuneObjectOptions.axaml'))
if ($State.IntunePanel) {
$State.IntunePanelTxt = $HostType::FindByName($State.IntunePanel, 'txtCompareIntuneObject')
$browse = $HostType::FindByName($State.IntunePanel, 'browseIntuneObject')
if ($browse) {
$browse.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
$sourceType = if ($st.Source) { $st.Source.PolicyType } else { $null }
if (-not $sourceType) {
Show-MessageBox "Select a source policy first." "Compare" "OK" "Warning" | Out-Null
return
}
$sourceId = if ($st.Source) { $st.Source.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:IntuneManagerAllRows) {
$initial = @(
$script:IntuneManagerAllRows |
Where-Object { $_.Source -and $_.Source.PolicyType -and $_.Source.PolicyType.ID -eq $sourceType.ID -and (-not $sourceId -or $_.Source.Id -ne $sourceId) } |
ForEach-Object { $_.Source }
)
}
$exclude = @()
if ($sourceId) { $exclude = @($sourceId) }
$picked = Show-PolicySearchDialog `
-Title ("Select Intune Object to Compare ({0})" -f $sourceType.Title) `
-DefaultPolicyTypeId $sourceType.ID `
-ExcludeIds $exclude `
-InitialItems $initial
if ($picked) {
$st.Target = $picked
if ($st.IntunePanelTxt) { $st.IntunePanelTxt.Text = [string]$picked.Name }
}
}))
}
}
}
if ($State.IntunePanelTxt -and $State.Target) {
$State.IntunePanelTxt.Text = [string]$State.Target.Name
}
$Container.Content = $State.IntunePanel
} else {
if (-not $State.FilePanel) {
$State.FilePanel = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CompareFileOptions.axaml'))
if ($State.FilePanel) {
$State.FilePanelTxt = $HostType::FindByName($State.FilePanel, 'txtCompareFile')
$browseFile = $HostType::FindByName($State.FilePanel, 'browseCompareObject')
# Pre-populate from saved last-file path so the user doesn't
# have to re-browse on every reopen.
$lastFile = Get-SettingStoreValue "Compare" "LastFile"
if ($lastFile -and $State.FilePanelTxt) { $State.FilePanelTxt.Text = $lastFile }
if ($browseFile) {
$browseFile.add_Click((ConvertTo-AvaloniaEventScriptBlock {
$st = $script:_compareFormState
if (-not $st) { return }
# Pick a sensible starting directory: Compare/LastFile's
# parent if set, else the source's PolicyType.Folder
# under LastUsedFullPath, else nothing (host falls back
# to default).
$startDir = $null
$cachedFile = Get-SettingStoreValue "Compare" "LastFile"
if ($cachedFile) {
try { $startDir = ([IO.FileInfo]$cachedFile).DirectoryName } catch { }
}
if (-not $startDir) {
$rootHint = Get-SettingStoreValue "" "LastUsedFullPath"
if ($rootHint) {
try { $rootHint = [IO.Directory]::GetParent($rootHint).FullName } catch { }
}
if ($rootHint -and $st.Source -and $st.Source.PolicyType -and $st.Source.PolicyType.Folder) {
$candidate = [IO.Path]::Combine($rootHint, $st.Source.PolicyType.Folder)
if ([IO.Directory]::Exists($candidate)) { $startDir = $candidate }
elseif ([IO.Directory]::Exists($rootHint)) { $startDir = $rootHint }
} elseif ($rootHint) {
$startDir = $rootHint
}
}
$picked = (Get-AvaloniaHost)::OpenFilePicker($script:Window, 'Select compare file', $startDir, 'JSON files', '*.json')
if ($picked) {
if ($st.FilePanelTxt) { $st.FilePanelTxt.Text = $picked }
Save-SettingStoreValue "Compare" "LastFile" $picked
}
}))
}
}
}
$Container.Content = $State.FilePanel
}
}
function Update-CompareGridRows
{
# $Filter: All (everything) / Mismatch (Match -eq $false) / Match (Match -eq $true).
# The 'Match' view hides ignored ($null) rows, same as 'Mismatch' hides them.
param($State, $Grid, [string]$Filter = 'All')
if (-not $Grid) { return }
# Build a TYPED list rather than piping through Where-Object: the
# pipeline stamps a PSObject wrapper on every item, and Avalonia's
# DataGrid binder reflects on the runtime type and renders blank cells
# against the wrapper (same trap as the assignments filter - see
# [[avalonia-binding-needs-clr-types]]).
$rows = [System.Collections.Generic.List[CompareResultRowItem]]::new()
foreach ($row in @($State.AllRows)) {
if (-not $row) { continue }
$keep = switch ($Filter) {
'Mismatch' { $row.Match -eq $false }
'Match' { $row.Match -eq $true }
default { $true }
}
if ($keep) { $rows.Add($row) }
}
$Grid.ItemsSource = $rows
}
function Update-CompareSummaryText
{
param($State, $TextBlock)
if (-not $TextBlock) { return }
$items = @($State.AllRows)
$total = $items.Count
$mismatches = @($items | Where-Object { $_.Match -eq $false }).Count
$skipped = @($items | Where-Object { $null -eq $_.Match }).Count
$matched = $total - $mismatches - $skipped
$tail = if ($skipped -gt 0) { ", $skipped ignored" } else { '' }
$TextBlock.Text = "$total properties - $matched matched, $mismatches mismatched$tail"
}
# Category / SubCategory columns are only meaningful for settings-based
# comparisons; Internal/Compare.ps1 raises CompareColumnVisibilityChanged to
# show or hide them. WPF wires this (IntuneManagerCompareUIWPF.ps1:1); the
# Avalonia port never did, so both columns stayed visible - and empty - for
# property compares.
Add-AppEventHandler "CompareColumnVisibilityChanged" "Set-CompareGridColumnVisibility"
function Set-CompareGridColumnVisibility
{
param($ShowCategory = $false, $ShowSubCategory = $false)
$st = $script:_compareFormState
$grid = if ($st) { $st.DgCompareInfo } else { $null }
if (-not $grid) { return }
foreach ($col in $grid.Columns) {
$header = [string]$col.Header
if ($header -eq 'Category') { $col.IsVisible = [bool]$ShowCategory }
if ($header -eq 'SubCategory') { $col.IsVisible = [bool]$ShowSubCategory }
}
}