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,18 @@
# Provider-agnostic authentication UI helpers.
#
# Phase 1 placeholder. The profile popup and account picker still live in
# MSGraphAuthenticationUI.ps1 and read MSAL state directly. Phase 2 will migrate
# them here once the MgGraph provider lands and we have a second consumer to
# validate the abstraction.
#
# When that happens, expect these helpers to live here:
# Show-AuthProviderProfilePopup - build the popup from $provider.GetUserInfo
# Add-AuthProviderCachedAccountRow - render a row for $provider.GetCachedAccounts
# Add-AuthProviderTenantRow - tenant switcher from $provider.GetAvailableTenants
# Update-AuthProviderUI - refresh the title-bar profile picture
#
# All of these will route through Get-AuthProvider so they work for any registered
# provider with no per-provider UI code.
# Currently empty by design. Do not delete — the file is part of the auth
# abstraction's stable file layout and Phase 2 will populate it.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
# "Permissions" button in the Profile popup: what the signed-in identity can
# actually do per policy type - the app's token scopes combined with the user's
# Intune role (Internal/EffectivePermissions.ps1). Thin caller of the public
# Get-GraphEffectivePermissions (R10); the popup wiring in
# MSGraphAuthenticationUIWPF.ps1 is a single AddXamlEvent line.
function Show-EffectivePermissionsDialog {
param()
$rows = @()
$raw = $null
try {
$rows = @(Get-GraphEffectivePermissions)
$raw = Get-GraphEffectivePermissions -Raw
}
catch { Write-LogError "Get-GraphEffectivePermissions failed" $_.Exception }
if($rows.Count -eq 0) {
$script:UIProvider.ShowMessageBox("No access token available. Sign in first.", "Permissions", "OK", "Information") | Out-Null
return
}
$header = Get-EffectivePermissionsHeaderText $raw
$grid = [System.Windows.Controls.Grid]::new()
$grid.RowDefinitions.Add([System.Windows.Controls.RowDefinition]@{ Height = "Auto" })
$grid.RowDefinitions.Add([System.Windows.Controls.RowDefinition]@{ Height = "*" })
$txt = [System.Windows.Controls.TextBlock]::new()
$txt.Text = $header
$txt.TextWrapping = "Wrap"
$txt.Margin = "5,5,5,10"
[System.Windows.Controls.Grid]::SetRow($txt, 0)
$grid.Children.Add($txt) | Out-Null
$dg = [System.Windows.Controls.DataGrid]::new()
Set-GridPixelScrolling $dg
$dg.IsReadOnly = $true
$dg.ItemsSource = @($rows | Sort-Object EffectiveLevel, Title |
Select-Object @{ n = "Type"; e = { $_.Title } },
@{ n = "Category"; e = { $_.ResourceCategory } },
@{ n = "Required"; e = { $_.Required } },
@{ n = "Token"; e = { $_.TokenAccess } },
@{ n = "Intune role"; e = { $_.RoleAccess } },
@{ n = "Effective"; e = { $_.EffectiveAccess } },
@{ n = "Result"; e = { $_.Result } },
Reason)
[System.Windows.Controls.Grid]::SetRow($dg, 1)
$grid.Children.Add($dg) | Out-Null
$script:UIProvider.ShowModalForm("Permissions", $grid)
}
# One-paragraph explanation of where the Intune-role half came from. Shared
# wording with the Avalonia dialog lives here in spirit only - each backend
# keeps its own copy under UI/<backend>/ (R12).
function Get-EffectivePermissionsHeaderText {
param($Context)
$refresh = "Changed roles (PIM, a new Intune role)? Click Refresh in the Profile popup - the app keeps using the token it signed in with until then."
if(-not $Context) {
return ("Token scopes only. The Intune role check does not apply to this token (app-only sign-in) or " +
"could not be read; see the log. Scope tags are not evaluated. $refresh")
}
$asOf = if($Context.AsOf) { " Token issued $($Context.AsOf.ToString('yyyy-MM-dd HH:mm'))." } else { "" }
if($Context.AllAllowed) {
return ("Token scopes combined with the Intune Administrator / Global Administrator directory role " +
"(full Intune access, no per-action lookup).$asOf $refresh")
}
# "N of M" only when the action catalogue was readable; M is how many actions
# Intune defines, so N of N would mean full access, not an empty answer.
$counts = "$($Context.Allowed.Count) resource actions allowed"
if($Context.Catalog) { $counts = "$($Context.Allowed.Count) of $($Context.Catalog.Count) resource actions allowed" }
return ("Token scopes combined with the signed-in user's Intune role permissions " +
"($counts).$asOf " +
"Scope tags are not evaluated: a user limited to some tags can still be refused on individual objects. $refresh")
}
@@ -0,0 +1,783 @@
# Bulk Assignments form + assignment-row helpers.
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Show-GraphBulkAssignmentsForm
{
$script:bulkAssignForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkAssignmentsForm.xaml"))
if(-not $script:bulkAssignForm) { return }
$script:dgBulkAssignObjects = $script:bulkAssignForm.FindName("dgBulkAssignObjects")
$script:dgBulkAssignList = $script:bulkAssignForm.FindName("dgBulkAssignList")
$script:txtBulkAssignStatus = $script:bulkAssignForm.FindName("txtBulkAssignStatus")
$assignmentSettings = [IntuneManagerAssignmentSettings]::new()
$script:bulkAssignForm.DataContext = $assignmentSettings
# ── Eligible types: use the same support gate as Set-GraphBulkAssignments
# so the UI does not offer groups/APIs that the command will skip.
$script:bulkAssignEligibleTypes = @($script:IntuneTypes | Where-Object {
Test-BulkAssignmentSupported $_
})
$eligibleTypeIds = @($script:bulkAssignEligibleTypes | ForEach-Object { $_.Id })
$script:bulkAssignEligibleGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and ($_.PolicyTypes | Where-Object { $_.Id -in $eligibleTypeIds })
})
# ── Object list (mirrors Bulk Scope Tag DataGrid setup) ──────────────────
$column = Get-GridCheckboxColumn "Selected"
$script:dgBulkAssignObjects.Columns.Add($column)
$script:bulkAssignSelectAllHeader = $column.Header
$script:bulkAssignSelectAllHeader.IsChecked = $true
$script:bulkAssignSelectAllHeader.add_Click({
foreach($item in $script:dgBulkAssignObjects.ItemsSource) {
$item.Selected = $this.IsChecked
}
$script:dgBulkAssignObjects.Items.Refresh()
})
$col = [System.Windows.Controls.DataGridTextColumn]::new()
$col.Header = "Object type"
$col.IsReadOnly = $true
$col.Binding = [System.Windows.Data.Binding]::new("Title")
$script:dgBulkAssignObjects.Columns.Add($col)
$script:bulkAssignMode = "Group"
Update-BulkAssignObjectList
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "rbBulkAssignViewGroup", "add_Checked", {
$script:bulkAssignMode = "Group"; Update-BulkAssignObjectList
if($script:bulkAssignSelectAllHeader) { $script:bulkAssignSelectAllHeader.IsChecked = $true }
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "rbBulkAssignViewType", "add_Checked", {
$script:bulkAssignMode = "Type"; Update-BulkAssignObjectList
if($script:bulkAssignSelectAllHeader) { $script:bulkAssignSelectAllHeader.IsChecked = $true }
})
# ── Action radio → settings.Action sync ───────────────────────────────────
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "rbBulkAssignAdd", "add_Checked", { $script:bulkAssignForm.DataContext.Action = "Add" })
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "rbBulkAssignReplace", "add_Checked", { $script:bulkAssignForm.DataContext.Action = "Replace" })
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "rbBulkAssignRemove", "add_Checked", { $script:bulkAssignForm.DataContext.Action = "Remove" })
# ── Assignments list — backing ObservableCollection so adds/removes show
# immediately. Each row carries display strings + the raw fields the
# public command consumes.
$script:colBulkAssignList = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
$script:dgBulkAssignList.ItemsSource = $script:colBulkAssignList
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignAddGroup", "add_click", {
Show-BulkAssignmentGroupPicker
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignAddAllDev", "add_click", {
Add-BulkAssignmentRow -TargetType "allDevicesAssignmentTarget" -GroupName "All Devices"
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignAddAllUsers", "add_click", {
Add-BulkAssignmentRow -TargetType "allLicensedUsersAssignmentTarget" -GroupName "All Users"
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignSettings", "add_click", {
$sel = $script:dgBulkAssignList.SelectedItem
if(-not $sel) {
$script:UIProvider.ShowMessageBox("Select an assignment row first.", "Bulk Assignments", "OK", "Information") | Out-Null
return
}
Show-BulkAssignmentSettingsDialog -Row $sel
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignRemoveSel", "add_click", {
$sel = $script:dgBulkAssignList.SelectedItem
if($sel) { [void]$script:colBulkAssignList.Remove($sel) }
})
# Double-click opens the settings dialog (less destructive than remove).
$script:dgBulkAssignList.Add_MouseDoubleClick({
$sel = $script:dgBulkAssignList.SelectedItem
if($sel) { Show-BulkAssignmentSettingsDialog -Row $sel }
})
# ── Apply ─────────────────────────────────────────────────────────────────
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignApply", "add_click", {
$btnApply = $script:bulkAssignForm.FindName("btnBulkAssignApply")
$btnClose = $script:bulkAssignForm.FindName("btnBulkAssignClose")
$selectionIds = Get-BulkAssignSelectedObjectIds
$unit = if($script:bulkAssignMode -eq "Type") { "policy type" } else { "object group" }
if($selectionIds.Count -eq 0) {
$script:UIProvider.ShowMessageBox("Select at least one $unit to update.", "Bulk Assignments", "OK", "Warning") | Out-Null
return
}
$settings = $script:bulkAssignForm.DataContext
$settings.Assignments = @($script:colBulkAssignList)
if($settings.Assignments.Count -eq 0) {
$script:UIProvider.ShowMessageBox("Add at least one assignment target before applying.", "Bulk Assignments", "OK", "Warning") | Out-Null
return
}
# Mass-wipe guard: Replace with one row that isn't the actual desired
# final state still wipes everything else. Warn before continuing.
if($settings.Action -eq "Replace") {
$proceed = $script:UIProvider.ShowMessageBox(
"Replace will OVERWRITE every existing assignment on every matched policy with only the rows above ($($settings.Assignments.Count) target(s)).`n`nContinue?",
"Confirm replace", "YesNo", "Warning")
if($proceed -ne "Yes") { return }
}
$assignmentSummary = ($settings.Assignments | ForEach-Object {
$parts = @($_.TargetTypeDisplay, $_.GroupName)
if($_.FilterId) { $parts += "filter: $($_.FilterName) ($($_.FilterType))" }
($parts -join ' / ')
}) -join "`n "
$filterText = if($settings.Filter) { $settings.Filter } else { "(none)" }
$confirm = $script:UIProvider.ShowMessageBox(@"
About to update assignments on every policy that matches:
Action : $($settings.Action)
Assignments :
$assignmentSummary
Name filter : $filterText
$unit count : $($selectionIds.Count)
Continue?
"@, "Confirm Bulk Assignment update", "YesNo", "Warning")
if($confirm -ne "Yes") { return }
if($btnApply) { $btnApply.IsEnabled = $false }
if($btnClose) { $btnClose.IsEnabled = $false }
# No pre-call Write-Status: Set-GraphBulkAssignments now sets its own two-line
# status ("Bulk assignments — <Action>" + sub-step) as soon as it starts.
$startParams = @{ AssignmentSettings = $settings }
if($script:bulkAssignMode -eq "Type") { $startParams.PolicyType = $selectionIds }
else { $startParams.PolicyGroup = $selectionIds }
try {
$summary = Set-GraphBulkAssignments @startParams
Write-Status $null
$unsupportedNote = if($summary.UnsupportedTypes -and $summary.UnsupportedTypes.Count -gt 0) {
"`nUnsupported types skipped: $($summary.UnsupportedTypes -join ', ')"
} else { "" }
$msg = ("Scanned: {0}`nMatched filter: {1}`nUpdated: {2}`nNo change: {3}`nFailed: {4}`nDuration: {5:hh\:mm\:ss}{6}" -f `
$summary.PoliciesScanned, $summary.PoliciesMatched, $summary.PoliciesUpdated, $summary.PoliciesSkipped, $summary.PoliciesFailed, $summary.Duration, $unsupportedNote)
$script:txtBulkAssignStatus.Text = ("Last run: updated {0}, no change {1}, failed {2}" -f $summary.PoliciesUpdated, $summary.PoliciesSkipped, $summary.PoliciesFailed)
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if($unknownNote) { $msg += "`n`n$unknownNote" }
$script:UIProvider.ShowMessageBox($msg, "Bulk Assignments", "OK", "Information") | Out-Null
try { $script:dgIntuneManagerObjects.Items.Refresh() }
catch { Write-LogDebug "Main grid refresh failed after bulk assignment run: $($_.Exception.Message)" }
}
catch {
Write-LogError "Bulk Assignments run failed" $_.Exception
$script:UIProvider.ShowMessageBox("Bulk Assignments run failed: $($_.Exception.Message)", "Bulk Assignments", "OK", "Error") | Out-Null
}
finally {
if($btnApply) { $btnApply.IsEnabled = $true }
if($btnClose) { $btnClose.IsEnabled = $true }
}
})
$script:UIProvider.AddXamlEvent($script:bulkAssignForm, "btnBulkAssignClose", "add_click", {
$script:bulkAssignForm = $null
$script:bulkAssignSelectAllHeader = $null
Show-ModalObject
})
$script:UIProvider.ShowModalForm("Bulk Assignments", $script:bulkAssignForm, $true)
}
# Rebuild the bulk-assign object DataGrid for the current view mode.
function Update-BulkAssignObjectList
{
if(-not $script:dgBulkAssignObjects) { return }
$script:bulkAssignObjects = @()
if($script:bulkAssignMode -eq "Type") {
$sorted = $script:bulkAssignEligibleTypes | Sort-Object Title
foreach($pt in $sorted) {
$script:bulkAssignObjects += New-Object PSObject -Property @{
Title = $pt.Title
Selected = $true
ObjectGroup = $null
ObjectType = $pt
}
}
}
else {
$sorted = $script:bulkAssignEligibleGroups | Sort-Object Title
foreach($grp in $sorted) {
$script:bulkAssignObjects += New-Object PSObject -Property @{
Title = $grp.Title
Selected = $true
ObjectGroup = $grp
ObjectType = $null
}
}
}
$script:dgBulkAssignObjects.ItemsSource = $script:bulkAssignObjects
}
function Get-BulkAssignSelectedObjectIds
{
if($script:bulkAssignMode -eq "Type") {
return @($script:bulkAssignObjects |
Where-Object { $_.Selected -eq $true -and $_.ObjectType -and $_.ObjectType.Id } |
ForEach-Object { $_.ObjectType.Id })
}
return @($script:bulkAssignObjects |
Where-Object { $_.Selected -eq $true -and $_.ObjectGroup -and $_.ObjectGroup.Id } |
ForEach-Object { $_.ObjectGroup.Id })
}
# Append a row to the assignments DataGrid. Used by All Devices / All Users
# buttons and by the group picker dialog after the user picks OK.
function Add-BulkAssignmentRow
{
param(
[Parameter(Mandatory)][string]$TargetType,
[string]$GroupId,
[string]$GroupName,
[string]$FilterId,
[string]$FilterName,
[string]$FilterType = "include",
# installIntent for mobileAppAssignment. Ignored by the public command
# for non-app types, so safe to set on every row.
[string]$Intent = "required"
)
# Display strings — keep the data layer plain (TargetType is the raw
# @odata.type fragment the public command uses; *Display columns are
# what the DataGrid binds to).
$typeDisplay = switch ($TargetType) {
"groupAssignmentTarget" { "Include group" }
"exclusionGroupAssignmentTarget" { "Exclude group" }
"allDevicesAssignmentTarget" { "All devices" }
"allLicensedUsersAssignmentTarget" { "All users" }
default { $TargetType }
}
$filterDisplay = if($FilterId) { "$FilterName ($FilterType)" } else { "" }
$row = [PSCustomObject]@{
TargetType = $TargetType
TargetTypeDisplay = $typeDisplay
GroupId = $GroupId
GroupName = if($GroupName) { $GroupName } else { $GroupId }
FilterId = $FilterId
FilterName = $FilterName
FilterType = $FilterType
FilterDisplay = $filterDisplay
Intent = $Intent
# Per-platform app assignment settings, keyed by Graph settings type
# name (e.g. "win32LobAppAssignmentSettings"). Absent key = no override
# for that type → Graph defaults are used on POST. Populated via the
# App settings... dialog (Show-BulkAssignmentSettingsDialog).
Settings = @{}
SettingsDisplay = "(none)"
}
# Dedupe — don't add the same target+intent twice. Two rows with the
# same target but different intents are intentionally allowed (Required
# vs Available is meaningful for apps).
$existing = @($script:colBulkAssignList | Where-Object {
$_.TargetType -eq $row.TargetType -and
[string]$_.GroupId -eq [string]$row.GroupId -and
[string]$_.FilterId -eq [string]$row.FilterId -and
([string]::IsNullOrEmpty($row.FilterId) -or $_.FilterType -eq $row.FilterType) -and
[string]$_.Intent -eq [string]$row.Intent
})
if($existing.Count -gt 0) {
$script:UIProvider.ShowMessageBox("That target is already in the list (with the same intent).", "Bulk Assignments", "OK", "Information") | Out-Null
return
}
[void]$script:colBulkAssignList.Add($row)
}
# ─── Bulk Assignments — per-platform settings ────────────────────────────────
#
# Schema for each settings type lives in $script:_bulkAssignSettingsSchema —
# one entry per TabItem in BulkAssignmentsSettings.xaml. Drives both the load
# (Row.Settings → UI controls) and save (UI controls → Row.Settings) passes
# from one piece of code per direction.
#
# Field types:
# bool — CheckBox.IsChecked
# string — TextBox.Text (empty skipped)
# int — TextBox.Text → int (empty skipped, non-numeric skipped)
# enum — ComboBox.SelectedItem (string)
# datetime — TextBox.Text passed through as-is (Graph accepts ISO8601)
# stringList — TextBox.Text split on newline / semicolon / comma (empties skipped)
#
# Nested complex types (win32LobAppRestartSettings, etc.) are encoded under a
# Nested array on the tab descriptor; the saved hashtable gets a nested
# hashtable + @odata.type stamp.
$script:_bulkAssignSettingsSchema = @(
@{ Apply = "chkApplyIosLob"; Type = "iosLobAppAssignmentSettings";
Fields = @(
@{ Control="chkIosLob_IsRemovable"; Key="isRemovable"; Kind="bool" }
@{ Control="chkIosLob_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosLob_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
@{ Control="txtIosLob_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyIosStore"; Type = "iosStoreAppAssignmentSettings";
Fields = @(
@{ Control="chkIosStore_IsRemovable"; Key="isRemovable"; Kind="bool" }
@{ Control="chkIosStore_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosStore_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
@{ Control="txtIosStore_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyIosVpp"; Type = "iosVppAppAssignmentSettings";
Fields = @(
@{ Control="chkIosVpp_DeviceLicensing"; Key="useDeviceLicensing"; Kind="bool" }
@{ Control="chkIosVpp_IsRemovable"; Key="isRemovable"; Kind="bool" }
@{ Control="chkIosVpp_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosVpp_PreventAutoUpdate"; Key="preventAutoAppUpdate"; Kind="bool" }
@{ Control="chkIosVpp_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
@{ Control="txtIosVpp_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyIosDdm"; Type = "iosDdmLobAppAssignmentSettings";
Fields = @(
@{ Control="txtIosDdm_Domains"; Key="associatedDomains"; Kind="stringList" }
@{ Control="chkIosDdm_DirectDownload"; Key="associatedDomainsDirectDownloadAllowed"; Kind="bool" }
@{ Control="chkIosDdm_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkIosDdm_TapToPay"; Key="tapToPayScreenLockEnabled"; Kind="bool" }
@{ Control="txtIosDdm_VpnId"; Key="vpnConfigurationId"; Kind="string" }
)}
@{ Apply = "chkApplyAndroidStore"; Type = "androidManagedStoreAppAssignmentSettings";
Fields = @(
@{ Control="cbAndroidStore_AutoUpdate"; Key="autoUpdateMode"; Kind="enum" }
@{ Control="txtAndroidStore_Tracks"; Key="androidManagedStoreAppTrackIds"; Kind="stringList" }
)}
@{ Apply = "chkApplyMacLob"; Type = "macOsLobAppAssignmentSettings";
Fields = @(
@{ Control="chkMacLob_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
)}
@{ Apply = "chkApplyMacVpp"; Type = "macOsVppAppAssignmentSettings";
Fields = @(
@{ Control="chkMacVpp_DeviceLicensing"; Key="useDeviceLicensing"; Kind="bool" }
@{ Control="chkMacVpp_PreventBackup"; Key="preventManagedAppBackup"; Kind="bool" }
@{ Control="chkMacVpp_PreventAutoUpdate"; Key="preventAutoAppUpdate"; Kind="bool" }
@{ Control="chkMacVpp_Uninstall"; Key="uninstallOnDeviceRemoval"; Kind="bool" }
)}
@{ Apply = "chkApplyMsStore"; Type = "microsoftStoreForBusinessAppAssignmentSettings";
Fields = @(
@{ Control="chkMsStore_DeviceContext"; Key="useDeviceContext"; Kind="bool" }
)}
@{ Apply = "chkApplyWinAppx"; Type = "windowsAppXAppAssignmentSettings";
Fields = @(
@{ Control="chkWinAppx_DeviceContext"; Key="useDeviceContext"; Kind="bool" }
)}
@{ Apply = "chkApplyWinUap"; Type = "windowsUniversalAppXAppAssignmentSettings";
Fields = @(
@{ Control="chkWinUap_DeviceContext"; Key="useDeviceContext"; Kind="bool" }
)}
@{ Apply = "chkApplyWin32"; Type = "win32LobAppAssignmentSettings";
Fields = @(
@{ Control="cbWin32_DeliveryOpt"; Key="deliveryOptimizationPriority"; Kind="enum" }
@{ Control="cbWin32_Notifications"; Key="notifications"; Kind="enum" }
)
Nested = @(
@{ Key="autoUpdateSettings"; Type="win32LobAppAutoUpdateSettings";
Fields=@(
@{ Control="cbWin32_Superseded"; Key="autoUpdateSupersededAppsState"; Kind="enum" }
)}
@{ Key="installTimeSettings"; Type="mobileAppInstallTimeSettings";
Fields=@(
@{ Control="chkWin32_UseLocalTime"; Key="useLocalTime"; Kind="bool" }
@{ Control="txtWin32_StartTime"; Key="startDateTime"; Kind="datetime" }
@{ Control="txtWin32_DeadlineTime"; Key="deadlineDateTime"; Kind="datetime" }
)}
@{ Key="restartSettings"; Type="win32LobAppRestartSettings";
Fields=@(
@{ Control="txtWin32_GracePeriod"; Key="gracePeriodInMinutes"; Kind="int" }
@{ Control="txtWin32_Countdown"; Key="countdownDisplayBeforeRestartInMinutes"; Kind="int" }
)}
)}
@{ Apply = "chkApplyWinGet"; Type = "winGetAppAssignmentSettings";
Fields = @(
@{ Control="cbWinGet_Notifications"; Key="notifications"; Kind="enum" }
)
Nested = @(
@{ Key="installTimeSettings"; Type="winGetAppInstallTimeSettings";
Fields=@(
@{ Control="chkWinGet_UseLocalTime"; Key="useLocalTime"; Kind="bool" }
@{ Control="txtWinGet_DeadlineTime"; Key="deadlineDateTime"; Kind="datetime" }
)}
@{ Key="restartSettings"; Type="winGetAppRestartSettings";
Fields=@(
@{ Control="txtWinGet_GracePeriod"; Key="gracePeriodInMinutes"; Kind="int" }
@{ Control="txtWinGet_Countdown"; Key="countdownDisplayBeforeRestartInMinutes"; Kind="int" }
)}
)}
@{ Apply = "chkApplyWinAutoUpdate"; Type = "windowsAutoUpdateCatalogAppAssignmentSettings";
Fields = @(
@{ Control="cbWinAutoUpdate_DeliveryOpt"; Key="deliveryOptimizationPriority"; Kind="enum" }
@{ Control="cbWinAutoUpdate_Notifications"; Key="notificationType"; Kind="enum" }
)
Nested = @(
@{ Key="installTimeSettings"; Type="windowsAutoUpdateCatalogAppInstallTimeSettings";
Fields=@(
@{ Control="chkWinAutoUpdate_UseLocalTime"; Key="useLocalTime"; Kind="bool" }
@{ Control="txtWinAutoUpdate_StartTime"; Key="startDateTime"; Kind="datetime" }
@{ Control="txtWinAutoUpdate_DeadlineTime"; Key="deadlineDateTime"; Kind="datetime" }
)}
@{ Key="restartSettings"; Type="windowsAutoUpdateCatalogAppRestartSettings";
Fields=@(
@{ Control="txtWinAutoUpdate_GracePeriod"; Key="gracePeriodInMinutes"; Kind="int" }
)}
)}
)
# Pull a field value out of a control and convert to the right Graph type.
# Returns $null when the field has no usable value (empty string, no
# selection, parse failure) — caller skips the key entirely so Graph keeps
# its default rather than receiving an empty/zero value.
function Get-BulkAssignmentSettingValue
{
param($Form, $Field)
$ctl = $Form.FindName($Field.Control)
if(-not $ctl) { return $null }
switch ($Field.Kind) {
"bool" { return [bool]$ctl.IsChecked }
"string" {
$v = [string]$ctl.Text
if([string]::IsNullOrWhiteSpace($v)) { return $null }
return $v
}
"int" {
$v = [string]$ctl.Text
if([string]::IsNullOrWhiteSpace($v)) { return $null }
$parsed = 0
if([int]::TryParse($v, [ref]$parsed)) { return $parsed }
return $null
}
"enum" {
$v = [string]$ctl.SelectedItem
if([string]::IsNullOrWhiteSpace($v)) { return $null }
return $v
}
"datetime" {
$v = [string]$ctl.Text
if([string]::IsNullOrWhiteSpace($v)) { return $null }
return $v # Graph accepts ISO8601 as a string in the JSON body
}
"stringList" {
$v = [string]$ctl.Text
if([string]::IsNullOrWhiteSpace($v)) { return $null }
$items = @($v -split "[`r`n;,]+" | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if($items.Count -eq 0) { return $null }
return ,$items
}
}
return $null
}
# Push a stored hashtable value back into the UI control. Inverse of
# Get-BulkAssignmentSettingValue. Tolerates missing keys (control left at
# its XAML-default state).
function Set-BulkAssignmentSettingValue
{
param($Form, $Field, $Value)
$ctl = $Form.FindName($Field.Control)
if(-not $ctl) { return }
switch ($Field.Kind) {
"bool" { if($null -ne $Value) { $ctl.IsChecked = [bool]$Value } }
"string" { if($null -ne $Value) { $ctl.Text = [string]$Value } }
"int" { if($null -ne $Value) { $ctl.Text = [string]$Value } }
"enum" { if($null -ne $Value) { $ctl.SelectedItem = [string]$Value } }
"datetime" { if($null -ne $Value) { $ctl.Text = [string]$Value } }
"stringList" {
if($null -ne $Value) {
$ctl.Text = (@($Value) -join [Environment]::NewLine)
}
}
}
}
function Show-BulkAssignmentSettingsDialog
{
param([Parameter(Mandatory)]$Row)
$form = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkAssignmentsSettings.xaml"))
if(-not $form) { return }
$script:_bulkAssignSettingsForm = $form
$script:_bulkAssignSettingsRow = $Row
# Populate enum combos once. Lists come from the Graph CSDL — keep them
# in lock-step with the metadata when Microsoft adds new enum members.
$enumLists = @{
cbAndroidStore_AutoUpdate = @("default","postponed","priority")
cbWin32_DeliveryOpt = @("notConfigured","foreground")
cbWin32_Notifications = @("showAll","showReboot","hideAll")
cbWin32_Superseded = @("notConfigured","enabled")
cbWinGet_Notifications = @("showAll","showReboot","hideAll")
cbWinAutoUpdate_DeliveryOpt = @("notConfigured","foreground")
cbWinAutoUpdate_Notifications = @("showAll","showReboot","hideAll")
cbScript_ScheduleType = @("Hourly","Daily","Once")
}
foreach($name in $enumLists.Keys) {
$ctl = $form.FindName($name)
if($ctl) { $ctl.ItemsSource = $enumLists[$name] }
}
# Custom load: Health Script tab uses a polymorphic runSchedule whose
# @odata.type depends on ScheduleType. Stored under the synthetic
# "deviceHealthScriptAssignment" key in Row.Settings (not a Graph type
# name — just an internal marker the public command consumes).
if($Row.Settings -and $Row.Settings.ContainsKey('deviceHealthScriptAssignment')) {
$hs = $Row.Settings['deviceHealthScriptAssignment']
if($hs) {
$form.FindName('chkApplyHealthScript').IsChecked = $true
if($hs.ContainsKey('runRemediationScript')) {
$form.FindName('chkScript_RunRemediation').IsChecked = [bool]$hs['runRemediationScript']
}
if($hs.ContainsKey('scheduleType')) { $form.FindName('cbScript_ScheduleType').SelectedItem = [string]$hs['scheduleType'] }
if($hs.ContainsKey('interval')) { $form.FindName('txtScript_Interval').Text = [string]$hs['interval'] }
if($hs.ContainsKey('time')) { $form.FindName('txtScript_Time').Text = [string]$hs['time'] }
if($hs.ContainsKey('useUtc')) { $form.FindName('chkScript_UseUtc').IsChecked = [bool]$hs['useUtc'] }
if($hs.ContainsKey('date')) { $form.FindName('txtScript_Date').Text = [string]$hs['date'] }
}
}
# Load existing values from Row.Settings into the controls.
foreach($tab in $script:_bulkAssignSettingsSchema) {
$existing = $null
if($Row.Settings -and $Row.Settings.ContainsKey($tab.Type)) {
$existing = $Row.Settings[$tab.Type]
}
if(-not $existing) { continue }
$applyCtl = $form.FindName($tab.Apply)
if($applyCtl) { $applyCtl.IsChecked = $true }
foreach($field in $tab.Fields) {
if($existing.ContainsKey($field.Key)) {
Set-BulkAssignmentSettingValue $form $field $existing[$field.Key]
}
}
if($tab.Nested) {
foreach($nested in $tab.Nested) {
if(-not $existing.ContainsKey($nested.Key)) { continue }
$nestedHash = $existing[$nested.Key]
if(-not $nestedHash) { continue }
foreach($field in $nested.Fields) {
if($nestedHash.ContainsKey($field.Key)) {
Set-BulkAssignmentSettingValue $form $field $nestedHash[$field.Key]
}
}
}
}
}
$script:UIProvider.AddXamlEvent($form, "btnBulkAssignSettingsOK", "add_click", {
$r = $script:_bulkAssignSettingsRow
$f = $script:_bulkAssignSettingsForm
$new = @{}
foreach($tab in $script:_bulkAssignSettingsSchema) {
$applyCtl = $f.FindName($tab.Apply)
if(-not $applyCtl -or -not $applyCtl.IsChecked) { continue }
$hash = @{}
foreach($field in $tab.Fields) {
$v = Get-BulkAssignmentSettingValue $f $field
if($null -ne $v) { $hash[$field.Key] = $v }
}
if($tab.Nested) {
foreach($nested in $tab.Nested) {
$nestedHash = @{}
foreach($field in $nested.Fields) {
$v = Get-BulkAssignmentSettingValue $f $field
if($null -ne $v) { $nestedHash[$field.Key] = $v }
}
if($nestedHash.Count -gt 0) {
$nestedHash["@odata.type"] = "#microsoft.graph.$($nested.Type)"
$hash[$nested.Key] = $nestedHash
}
}
}
# Only include the tab if SOMETHING is set. Otherwise it's just
# the apply-checkbox ticked with all defaults — no value to send.
if($hash.Count -gt 0) { $new[$tab.Type] = $hash }
}
# Custom save: Health Script tab. Stored as a flat hashtable; the
# public command picks the schedule @odata.type at POST time from
# the scheduleType value here.
$applyHs = $f.FindName('chkApplyHealthScript')
if($applyHs -and $applyHs.IsChecked) {
$hsHash = @{
runRemediationScript = [bool]$f.FindName('chkScript_RunRemediation').IsChecked
}
$st = [string]$f.FindName('cbScript_ScheduleType').SelectedItem
if($st) {
$hsHash['scheduleType'] = $st
$intervalText = [string]$f.FindName('txtScript_Interval').Text
$parsedInterval = 0
if(-not [string]::IsNullOrWhiteSpace($intervalText) -and [int]::TryParse($intervalText, [ref]$parsedInterval)) {
$hsHash['interval'] = $parsedInterval
}
if($st -in @('Daily','Once')) {
$timeText = [string]$f.FindName('txtScript_Time').Text
if(-not [string]::IsNullOrWhiteSpace($timeText)) { $hsHash['time'] = $timeText.Trim() }
$hsHash['useUtc'] = [bool]$f.FindName('chkScript_UseUtc').IsChecked
}
if($st -eq 'Once') {
$dateText = [string]$f.FindName('txtScript_Date').Text
if(-not [string]::IsNullOrWhiteSpace($dateText)) { $hsHash['date'] = $dateText.Trim() }
}
}
$new['deviceHealthScriptAssignment'] = $hsHash
}
$r.Settings = $new
$r.SettingsDisplay = if($new.Count -gt 0) { "$($new.Count) platform(s)" } else { "(none)" }
try { $script:dgBulkAssignList.Items.Refresh() } catch { }
$script:_bulkAssignSettingsForm = $null
$script:_bulkAssignSettingsRow = $null
Close-TopModalObject
})
$script:UIProvider.AddXamlEvent($form, "btnBulkAssignSettingsCancel", "add_click", {
$script:_bulkAssignSettingsForm = $null
$script:_bulkAssignSettingsRow = $null
Close-TopModalObject
})
$script:UIProvider.ShowModalForm("App settings - $($Row.GroupName)", $form, $true)
}
# Group-picker dialog. Searches AAD groups via Graph (startsWith), lets the
# user pick include/exclude direction and optionally attach a deviceAnd
# AppManagementAssignmentFilter. Reuses the global modal scaffolding so the
# parent Bulk Assignments form stays put underneath.
function Show-BulkAssignmentGroupPicker
{
$picker = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkAssignmentsGroupPicker.xaml"))
if(-not $picker) { return }
$script:_bulkAssignGroupPicker = $picker
$txtSearch = $picker.FindName("txtGroupSearch")
$btnSearch = $picker.FindName("btnGroupSearch")
$lstResults = $picker.FindName("lstGroupResults")
$cbFilter = $picker.FindName("cbGroupFilter")
$cbIntent = $picker.FindName("cbGroupIntent")
if(-not $txtSearch -or -not $btnSearch -or -not $lstResults -or -not $cbFilter -or -not $cbIntent) {
Write-Log "Bulk Assignments group picker XAML loaded, but required controls were not found" 3
return
}
# Populate assignment filters once. Empty entry = no filter.
$tokenId = Get-DefaultTokenId
$filters = @([PSCustomObject]@{ Id = $null; Name = "(no filter)" })
try {
$resp = Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters" -TokenId $tokenId -AllPages
if($resp -and $resp.value) {
foreach($f in @($resp.value | Sort-Object displayName)) {
$filters += [PSCustomObject]@{ Id = [string]$f.id; Name = [string]$f.displayName }
}
}
}
catch { Write-LogDebug "Bulk Assignments: failed to load assignment filters: $($_.Exception.Message)" }
$cbFilter.ItemsSource = $filters
$cbFilter.SelectedIndex = 0
# installIntent values from Graph's mobileAppAssignment schema. Required
# is the most common bulk action so make it the default.
$intents = @(
[PSCustomObject]@{ Name = "Required"; Value = "required" }
[PSCustomObject]@{ Name = "Available"; Value = "available" }
[PSCustomObject]@{ Name = "Uninstall"; Value = "uninstall" }
[PSCustomObject]@{ Name = "Available without enrollment"; Value = "availableWithoutEnrollment" }
[PSCustomObject]@{ Name = "Not available (hidden)"; Value = "notAvailable" }
)
$cbIntent.ItemsSource = $intents
$cbIntent.SelectedValue = "required"
$searchAction = {
$activePicker = $script:_bulkAssignGroupPicker
$searchBox = if($activePicker) { $activePicker.FindName("txtGroupSearch") } else { $null }
$resultsList = if($activePicker) { $activePicker.FindName("lstGroupResults") } else { $null }
if(-not $searchBox -or -not $resultsList -or -not ($resultsList -is [System.Windows.Controls.ItemsControl])) {
Write-Log "Bulk Assignments: group search controls were not available" 3
return
}
$term = [string]$searchBox.Text
if($term.Length -lt 1) {
$resultsList.ItemsSource = $null
return
}
$escaped = $term.Replace("'", "''")
$url = "groups?`$top=25&`$select=id,displayName&`$filter=startswith(displayName,'$escaped')"
try {
$resp = Invoke-MSGraphAPI -Url $url -TokenId (Get-DefaultTokenId)
if($resp -and $resp.value) {
$resultsList.ItemsSource = @($resp.value | Sort-Object displayName)
}
else {
$resultsList.ItemsSource = @()
}
}
catch {
Write-LogError "Bulk Assignments: group search failed" $_.Exception
$resultsList.ItemsSource = @()
}
}
$btnSearch.Add_Click($searchAction)
$txtSearch.Add_KeyDown({
param($src, $e)
if($e.Key -eq "Return") { & $searchAction }
})
$script:UIProvider.AddXamlEvent($picker, "btnGroupPickerOK", "add_click", {
$sel = $script:_bulkAssignGroupPicker.FindName("lstGroupResults").SelectedItem
if(-not $sel) {
$script:UIProvider.ShowMessageBox("Pick a group from the search results first.", "Bulk Assignments", "OK", "Warning") | Out-Null
return
}
$isExclude = [bool]$script:_bulkAssignGroupPicker.FindName("rbGroupExclude").IsChecked
$targetType = if($isExclude) { "exclusionGroupAssignmentTarget" } else { "groupAssignmentTarget" }
$filterItem = $script:_bulkAssignGroupPicker.FindName("cbGroupFilter").SelectedItem
$filterId = if($filterItem) { [string]$filterItem.Id } else { $null }
$filterName = if($filterItem -and $filterId) { [string]$filterItem.Name } else { $null }
$filterType = "include"
if($filterId -and [bool]$script:_bulkAssignGroupPicker.FindName("rbGroupFilterExclude").IsChecked) {
$filterType = "exclude"
}
$intent = [string]$script:_bulkAssignGroupPicker.FindName("cbGroupIntent").SelectedValue
if(-not $intent) { $intent = "required" }
Add-BulkAssignmentRow `
-TargetType $targetType `
-GroupId ([string]$sel.id) `
-GroupName ([string]$sel.displayName) `
-FilterId $filterId `
-FilterName $filterName `
-FilterType $filterType `
-Intent $intent
$script:_bulkAssignGroupPicker = $null
Close-TopModalObject
})
$script:UIProvider.AddXamlEvent($picker, "btnGroupPickerCancel", "add_click", {
$script:_bulkAssignGroupPicker = $null
Close-TopModalObject
})
$script:UIProvider.ShowModalForm("Pick a group", $picker, $true)
}
@@ -0,0 +1,100 @@
# Bulk Copy form (ported from the original project's Copy extension).
# Thin WPF wrapper around the public Start-GraphBulkCopy driver — the UI only
# collects the two name patterns and the selected policy types (R10).
function Show-GraphBulkCopyForm
{
$script:bulkCopyForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkCopy.xaml"), $true)
if(-not $script:bulkCopyForm) { return }
$script:dgBulkCopyObjects = $script:bulkCopyForm.FindName("dgBulkCopyObjects")
$script:UIProvider.SetXamlProperty($script:bulkCopyForm, "txtCopyFromPattern", "Text", (Get-SettingStoreValue "Copy" "CopyFromPattern"))
$script:UIProvider.SetXamlProperty($script:bulkCopyForm, "txtCopyToPattern", "Text", (Get-SettingStoreValue "Copy" "CopyToPattern"))
# Rows are policy TYPES (matching the original Bulk Copy, which listed every
# type in the active view), all selected by default.
$script:copyObjects = @()
foreach($intuneType in $script:IntuneTypes)
{
if(-not $intuneType.Title) { continue }
if($intuneType.ShowButtons -is [Object[]] -and $intuneType.ShowButtons -notcontains "Copy") { continue }
$script:copyObjects += New-Object PSObject -Property @{
Title = $intuneType.Title
Selected = $true
ObjectType = $intuneType
}
}
$script:copyObjects = @($script:copyObjects | Sort-Object -Property Title)
$column = Get-GridCheckboxColumn "Selected"
$script:dgBulkCopyObjects.Columns.Add($column)
$column.Header.IsChecked = $true
$column.Header.add_Click({
foreach($Item in $script:dgBulkCopyObjects.ItemsSource)
{
$Item.Selected = $this.IsChecked
}
$script:dgBulkCopyObjects.Items.Refresh()
})
$binding = [System.Windows.Data.Binding]::new("Title")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "Object type"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:dgBulkCopyObjects.Columns.Add($column)
$script:dgBulkCopyObjects.ItemsSource = $script:copyObjects
$script:UIProvider.AddXamlEvent($script:bulkCopyForm, "btnClose", "add_click", {
$script:bulkCopyForm = $null
Show-ModalObject
})
$script:UIProvider.AddXamlEvent($script:bulkCopyForm, "btnStartCopy", "add_click", {
$copyFrom = ($script:UIProvider.GetXamlProperty($script:bulkCopyForm, "txtCopyFromPattern", "Text")).Trim()
$copyTo = ($script:UIProvider.GetXamlProperty($script:bulkCopyForm, "txtCopyToPattern", "Text")).Trim()
if(-not $copyFrom -or -not $copyTo)
{
$script:UIProvider.ShowMessageBox("Both name patterns must be specified", "Error", "OK", "Error")
return
}
$selectedTypes = @($script:copyObjects | Where-Object Selected -eq $true)
if($selectedTypes.Count -eq 0)
{
$script:UIProvider.ShowMessageBox("No object types selected.`n`nSelect the types you want to copy.", "Error", "OK", "Error")
return
}
Save-SettingStoreValue "Copy" "CopyFromPattern" $copyFrom
Save-SettingStoreValue "Copy" "CopyToPattern" $copyTo
Write-Status "Copy objects" -Block
try
{
$summary = Start-GraphBulkCopy -CopyFromPattern $copyFrom -CopyToPattern $copyTo `
-PolicyType @($selectedTypes | ForEach-Object { $_.ObjectType.Id })
Write-Status $null
$msg = ("Copied {0} object(s) across {1} type(s) ({2} skipped because the target name exists) in {3:hh\:mm\:ss}." -f `
$summary.Copied, $summary.Types, $summary.Skipped, $summary.Duration)
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if($unknownNote) { $msg += "`n`n$unknownNote" }
$script:UIProvider.ShowMessageBox($msg, "Bulk Copy", "OK", "Information") | Out-Null
}
catch
{
Write-Status $null
Write-LogError "Bulk copy failed" $_.Exception
$script:UIProvider.ShowMessageBox("Bulk copy failed:`n`n$($_.Exception.Message)", "Bulk Copy", "OK", "Error") | Out-Null
}
# Refresh the current view so any copies in the active type show up.
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
})
$script:UIProvider.ShowModalForm("Bulk Copy Objects", $script:bulkCopyForm, $true)
}
@@ -0,0 +1,104 @@
# Bulk Delete form.
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Show-GraphBulkDeleteForm
{
$script:bulkDeleteForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkDeleteForm.xaml"), $true)
if(-not $script:bulkDeleteForm) { return }
$script:dgBulkDeleteObjects = $script:bulkDeleteForm.FindName("dgBulkDeleteObjects")
$script:UIProvider.SetXamlProperty($script:bulkDeleteForm, "txtDeleteNameFilter", "Text", (Get-SettingStoreValue "" "DeleteNameFilter"))
$script:deleteObjects = @()
foreach($intuneGroup in $script:IntuneGroups)
{
if(-not $intuneGroup.Title) { continue }
if($intuneGroup.ShowButtons -is [Object[]] -and $intuneGroup.ShowButtons -notcontains "Delete") { continue }
$script:deleteObjects += New-Object PSObject -Property @{
Title = $intuneGroup.Title
Selected = $false
ObjectGroup = $intuneGroup
}
}
$column = Get-GridCheckboxColumn "Selected"
$script:dgBulkDeleteObjects.Columns.Add($column)
$column.Header.IsChecked = $false
$column.Header.add_Click({
foreach($Item in $script:dgBulkDeleteObjects.ItemsSource)
{
$Item.Selected = $this.IsChecked
}
$script:dgBulkDeleteObjects.Items.Refresh()
})
$binding = [System.Windows.Data.Binding]::new("Title")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "Object type"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:dgBulkDeleteObjects.Columns.Add($column)
$script:dgBulkDeleteObjects.ItemsSource = $script:deleteObjects
$script:UIProvider.AddXamlEvent($script:bulkDeleteForm, "btnClose", "add_click", {
$script:bulkDeleteForm = $null
Show-ModalObject
})
$script:UIProvider.AddXamlEvent($script:bulkDeleteForm, "btnDelete", "add_click", {
$selectedGroups = @($script:deleteObjects | Where-Object Selected -eq $true)
if($selectedGroups.Count -eq 0)
{
$script:UIProvider.ShowMessageBox("No object types selected.`n`nSelect the types you want to delete.", "Error", "OK", "Error")
return
}
$nameFilter = ($script:UIProvider.GetXamlProperty($script:bulkDeleteForm, "txtDeleteNameFilter", "Text")).Trim()
Save-SettingStoreValue "" "DeleteNameFilter" $nameFilter
$confirmMsg = "Are you sure you want to delete all objects of the selected type(s)?`n`n$($selectedGroups.Count) type(s) selected`n`nEnvironment: $script:OrganizationName"
if($nameFilter) { $confirmMsg += "`nName filter: $nameFilter" }
if(($script:UIProvider.ShowMessageBox($confirmMsg, "Delete Objects?", "YesNo", "Warning")) -ne "Yes")
{
return
}
# The UI is a thin caller of the public driver (R10) — the confirm
# prompt above is the UI's job; the delete loop itself runs headless
# via Start-GraphBulkDelete.
Write-Status "Bulk delete" -Block
try
{
$summary = Start-GraphBulkDelete -Filter $nameFilter `
-PolicyGroup @($selectedGroups | ForEach-Object { $_.ObjectGroup.ID })
# Delete shows nothing on plain success; an id that matched nothing is
# the one outcome the user must not miss.
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if($unknownNote) {
$script:UIProvider.ShowMessageBox($unknownNote, "Bulk Delete", "OK", "Warning") | Out-Null
}
}
catch
{
Write-LogError "Bulk delete failed" $_.Exception
$script:UIProvider.ShowMessageBox("Bulk delete failed:`n`n$($_.Exception.Message)", "Bulk Delete", "OK", "Error") | Out-Null
}
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
Write-Status ""
})
$script:UIProvider.ShowModalForm("Bulk Delete", $script:bulkDeleteForm, $true)
}
#region Bulk Documentation
# Thin WPF wrapper around Start-GraphBulkDocumentation. The same public driver
# powers the silent batch path (Tests/Setup/Invoke-AutomationOrchestrator) so
# every code path goes through the same engine.
@@ -0,0 +1,495 @@
# Bulk Documentation form + per-view Show-IntuneManagerDocumentForm caller.
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Set-WpfDocumentationCombo {
param($Form, [string]$Name, $Items, $SelectedValue)
$control = $Form.FindName($Name)
if (-not $control) { return }
$control.ItemsSource = Get-UIItemsArray $Items
if ($null -ne $SelectedValue) { $control.SelectedValue = $SelectedValue }
if ($control.SelectedIndex -lt 0 -and $control.Items.Count -gt 0) { $control.SelectedIndex = 0 }
}
# Show/hide $TargetNames whenever $TriggerName's SelectedValue matches
# $VisibleWhen. Mirrors the OLD project's per-output-form selectionChanged
# wiring (DocumentationWord.psm1 ~L126, Documentation.psm1 ~L5029) so the
# Custom Properties row only appears for Output Properties = "custom" and
# the limit-length grid only appears for Output Level = "limited".
function Set-WpfDocumentationConditionalVisibility {
param(
$Form,
[string]$TriggerName,
[string]$VisibleWhen,
[string[]]$TargetNames
)
$trigger = $Form.FindName($TriggerName)
if (-not $trigger) { return }
$targets = @($TargetNames | ForEach-Object { $Form.FindName($_) } | Where-Object { $_ })
if ($targets.Count -eq 0) { return }
$apply = {
param($selectedValue)
$vis = if ([string]$selectedValue -eq $VisibleWhen) { 'Visible' } else { 'Collapsed' }
foreach ($t in $targets) { $t.Visibility = $vis }
}.GetNewClosure()
& $apply $trigger.SelectedValue
$trigger.Add_SelectionChanged({
param($s, $e)
& $apply $s.SelectedValue
}.GetNewClosure())
}
function Initialize-WpfDocumentationOptions {
param($Form)
Set-WpfDocumentationCombo $Form 'cbDocumentationLanguage' (Get-DocumentationLanguages | Sort-Object EnglishName) (Get-DocumentationSetting 'Language' 'en')
Set-WpfDocumentationCombo $Form 'cbDocumentationPropertySeparator' @(',',';','-','|') (Get-DocumentationSetting 'PropertySeparator' ';')
Set-WpfDocumentationCombo $Form 'cbDocumentationObjectSeparator' @(
[PSCustomObject]@{ Name='New line'; Value=[Environment]::NewLine },
[PSCustomObject]@{ Name=';'; Value=';' },
[PSCustomObject]@{ Name='|'; Value='|' }
) (Get-DocumentationSetting 'ObjectSeparator' ([Environment]::NewLine))
Set-WpfDocumentationCombo $Form 'cbNotConfiguredText' @(
[PSCustomObject]@{Name='Not configured (localized)';Value='notConfigured'},
[PSCustomObject]@{Name='Empty';Value='empty'},
[PSCustomObject]@{Name="Don't change";Value='asis'}
) (Get-DocumentationSetting 'NotConfiguredText' 'notConfigured')
Set-WpfDocumentationCombo $Form 'cbValueOutputProperty' @(
[PSCustomObject]@{Name='Value';Value='value'},
[PSCustomObject]@{Name='Value with label';Value='valueWithLabel'}
) (Get-DocumentationSetting 'ValueOutputProperty' 'value')
$propertyModes = @([PSCustomObject]@{Name='Simple';Value='simple'},[PSCustomObject]@{Name='Extended';Value='extended'},[PSCustomObject]@{Name='Custom';Value='custom'})
$fileModes = @([PSCustomObject]@{Name='Single file';Value='Full'},[PSCustomObject]@{Name='One file per object';Value='Object'})
Set-WpfDocumentationCombo $Form 'cbCSVDocumentationProperties' $propertyModes (Get-DocumentationSetting 'CSVExportProperties' 'simple')
Set-WpfDocumentationCombo $Form 'cbCSVDelimiter' @('',',',';','-','|') (Get-DocumentationSetting 'CSVDelimiter' '')
Set-WpfDocumentationCombo $Form 'cbJsonOutputFileType' @([PSCustomObject]@{Name='Single file';Value='Full'},[PSCustomObject]@{Name='One file per object type';Value='ObjectType'}) (Get-DocumentationSetting 'JSONOutputFileType' 'Full')
Set-WpfDocumentationCombo $Form 'cbHTMLDocumentFileType' $fileModes (Get-DocumentationSetting 'HTMLDocumentFileType' 'Full')
Set-WpfDocumentationCombo $Form 'cbAtlassianDocumentFileType' $fileModes (Get-DocumentationSetting 'AtlassianDocumentFileType' 'Full')
Set-WpfDocumentationCombo $Form 'cbMDDocumentFileType' $fileModes (Get-DocumentationSetting 'MDDocumentFileType' 'Full')
Set-WpfDocumentationCombo $Form 'cbWordExportProperties' $propertyModes (Get-DocumentationSetting 'WordExportProperties' 'simple')
Set-WpfDocumentationCombo $Form 'cbWordDocumentFormat' @([PSCustomObject]@{Name='DOCX';Value='wdFormatDocumentDefault'},[PSCustomObject]@{Name='Strict Open XML';Value='wdFormatStrictOpenXMLDocument'},[PSCustomObject]@{Name='PDF';Value='wdFormatPDF'}) (Get-DocumentationSetting 'WordDocumentFormat' 'wdFormatDocumentDefault')
Set-WpfDocumentationCombo $Form 'cbWordDocumentationLevel' @([PSCustomObject]@{Name='Full';Value='full'},[PSCustomObject]@{Name='Limited';Value='limited'},[PSCustomObject]@{Name='Basic';Value='basic'}) (Get-DocumentationSetting 'WordDocumentationLevel' 'full')
Set-WpfDocumentationCombo $Form 'cbWordTableCaptionPosition' @([PSCustomObject]@{Name='Below table';Value='below'},[PSCustomObject]@{Name='Above table';Value='above'}) (Get-DocumentationSetting 'WordTableCaptionPosition' 'below')
$defaults = @{
txtCSVDocumentationPath='CSVDocumentationPath'; txtCSVCustomProperties='CSVCustomDisplayProperties'; txtJsonDocumentName='JSONDocumentName'
txtHTMLDocumentName='HTMLDocumentName'; txtHTMLCSSFile='HTMLCSSFile'; txtHTMLTitleProperty='HTMLTitleProperty'
txtAtlassianDocumentName='AtlassianDocumentName'; txtAtlassianTitleProperty='AtlassianTitleProperty'
txtMDDocumentName='MDDocumentName'; txtMDCSSFile='MDCSSFile'; txtMDTitleProperty='MDTitleProperty'
txtWordDocumentName='WordDocumentName'; txtWordDocumentTemplate='WordDocumentTemplate'; txtWordCustomDisplayProperties='WordCustomDisplayProperties'
txtWordDocumentationLimitMaxLength='WordDocumentationLimitMaxLength'; txtWordDocumentationLimitTruncateLength='WordDocumentationLimitTruncateLength'
txtWordTitleProperty='WordTitleProperty'; txtWordSubjectProperty='WordSubjectProperty'; txtWordContentControls='WordContentControls'
txtWordCoverPage='WordCoverPage'; txtWordHeader1Style='WordHeader1Style'; txtWordHeader2Style='WordHeader2Style'; txtWordHeader3Style='WordHeader3Style'
txtWordTableStyle='WordTableStyle'; txtWordTableHeaderStyle='WordTableHeaderStyle'; txtWordCategoryHeaderStyle='WordCategoryHeaderStyle'
txtWordSubCategoryHeaderStyle='WordSubCategoryHeaderStyle'; txtWordTableTextStyle='WordTableTextStyle'
txtWordScriptTableStyle='WordScriptTableStyle'; txtWordScriptStyle='WordScriptStyle'
}
foreach($name in $defaults.Keys) {
$c = $Form.FindName($name)
# Some output XAML files don't carry every control yet (e.g. the
# WPF MD form omits TitleProperty); skip silently rather than
# NullReferenceException-on-property-set.
if ($c) { $c.Text = [string](Get-DocumentationSetting $defaults[$name] '') }
}
foreach($pair in @(
@('chkSkipNotConfigured','SkipNotConfigured',$false),@('chkSkipDefaultValues','SkipDefaultValues',$false),@('chkSkipDisabled','SkipDisabled',$true),
@('chkSetUnconfiguredValue','SetUnconfiguredValue',$true),@('chkSetDefaultValue','SetDefaultValue',$false),@('chkIncludeScripts','IncludeScripts',$true),
@('chkExcludeScriptSignature','ExcludeScriptSignature',$false),@('chkExcludeAssignments','ExcludeAssignments',$false),@('chkIncludePolicyId','IncludePolicyId',$false),@('chkSkipDocumentInfo','SkipDocumentInfo',$false),@('chkFallbackDocumentation','FallbackDocumentation',$true),
@('chkCSVAddObjectType','CSVAddObjectType',$true),@('chkCSVAddCompanyName','CSVAddCompanyName',$false),@('chkJsonOpenFile','JSONOpenFile',$true),
@('chkHTMLOpenFile','HTMLOpenFile',$true),@('chkAtlassianOpenFile','AtlassianOpenFile',$true),
@('chkMDIncludeCSS','MDIncludeCSS',$true),@('chkMDOpenFile','MDOpenFile',$true),@('chkMDDocumentSkipDate','MDDocumentSkipDate',$false),
@('chkWordDocumentationLimitAttach','WordDocumentationLimitAttach',$false),@('chkWordAddCategories','WordAddCategories',$true),@('chkWordAddSubCategories','WordAddSubCategories',$true),
@('chkWordOpenDocument','WordOpenDocument',$true),@('chkWordAttachJsonFile','WordAttachJsonFile',$false)
)) {
$c = $Form.FindName($pair[0])
if ($c) { $c.IsChecked = (Get-DocumentationSetting $pair[1] $pair[2]) -eq $true }
}
# Conditional-visibility wiring: matches the OLD project's per-output-form
# selectionChanged handlers. The Custom Properties row only appears for
# Output Properties = "custom"; the limit-length grid only appears for
# Output Level = "limited".
Set-WpfDocumentationConditionalVisibility -Form $Form -TriggerName 'cbWordExportProperties' -VisibleWhen 'custom' `
-TargetNames @('spWordCustomProperties','txtWordCustomDisplayProperties')
Set-WpfDocumentationConditionalVisibility -Form $Form -TriggerName 'cbWordDocumentationLevel' -VisibleWhen 'limited' `
-TargetNames @('gdWordDocumentationLimitOptions')
Set-WpfDocumentationConditionalVisibility -Form $Form -TriggerName 'cbCSVDocumentationProperties' -VisibleWhen 'custom' `
-TargetNames @('spCSVCustomProperties','txtCSVCustomProperties')
}
function Get-WpfDocumentationOptions {
param($Form)
# Missing-control safety: if a XAML file doesn't carry a referenced
# control (e.g. legacy form without the new field), return $null/empty
# rather than tripping NullReferenceException. Save-DocumentationOptionsDefaults
# later filters $null so we don't overwrite the user's persisted setting
# with garbage on click-Export.
function Txt($n) { $c = $Form.FindName($n); if ($c) { [string]$c.Text } else { $null } }
function Sel($n) { $c = $Form.FindName($n); if ($c) { $c.SelectedValue } else { $null } }
function Chk($n) { $c = $Form.FindName($n); if ($c) { [bool]$c.IsChecked } else { $null } }
@{
Language=Sel 'cbDocumentationLanguage'; PropertySeparator=Sel 'cbDocumentationPropertySeparator'; ObjectSeparator=Sel 'cbDocumentationObjectSeparator'; NameFilter=Txt 'txtDocumentFilter'; SourceFolder=Txt 'txtDocumentFromFolder'
SkipNotConfigured=Chk 'chkSkipNotConfigured'; SkipDefaultValues=Chk 'chkSkipDefaultValues'; SkipDisabled=Chk 'chkSkipDisabled'
SetUnconfiguredValue=Chk 'chkSetUnconfiguredValue'; SetDefaultValue=Chk 'chkSetDefaultValue'; IncludeScripts=Chk 'chkIncludeScripts'
ExcludeScriptSignature=Chk 'chkExcludeScriptSignature'; ExcludeAssignments=Chk 'chkExcludeAssignments'; IncludePolicyId=Chk 'chkIncludePolicyId'
SkipDocumentInfo=Chk 'chkSkipDocumentInfo'; FallbackDocumentation=Chk 'chkFallbackDocumentation'
NotConfiguredText=Sel 'cbNotConfiguredText'; ValueOutputProperty=Sel 'cbValueOutputProperty'
Outputs=@{
csv=@{CSVDocumentationPath=Txt 'txtCSVDocumentationPath';CSVExportProperties=Sel 'cbCSVDocumentationProperties';CSVCustomDisplayProperties=Txt 'txtCSVCustomProperties';CSVDelimiter=Sel 'cbCSVDelimiter';CSVAddObjectType=Chk 'chkCSVAddObjectType';CSVAddCompanyName=Chk 'chkCSVAddCompanyName'}
json=@{JSONDocumentName=Txt 'txtJsonDocumentName';JSONOpenFile=Chk 'chkJsonOpenFile';JSONOutputFileType=Sel 'cbJsonOutputFileType'}
html=@{HTMLDocumentName=Txt 'txtHTMLDocumentName';HTMLCSSFile=Txt 'txtHTMLCSSFile';HTMLTitleProperty=Txt 'txtHTMLTitleProperty';HTMLOpenFile=Chk 'chkHTMLOpenFile';HTMLDocumentFileType=Sel 'cbHTMLDocumentFileType'}
atlassian=@{AtlassianDocumentName=Txt 'txtAtlassianDocumentName';AtlassianTitleProperty=Txt 'txtAtlassianTitleProperty';AtlassianOpenFile=Chk 'chkAtlassianOpenFile';AtlassianDocumentFileType=Sel 'cbAtlassianDocumentFileType'}
md=@{MDDocumentName=Txt 'txtMDDocumentName';MDCSSFile=Txt 'txtMDCSSFile';MDTitleProperty=Txt 'txtMDTitleProperty';MDIncludeCSS=Chk 'chkMDIncludeCSS';MDOpenFile=Chk 'chkMDOpenFile';MDDocumentSkipDate=Chk 'chkMDDocumentSkipDate';MDDocumentFileType=Sel 'cbMDDocumentFileType'}
word=@{WordDocumentName=Txt 'txtWordDocumentName';WordDocumentTemplate=Txt 'txtWordDocumentTemplate';WordExportProperties=Sel 'cbWordExportProperties';WordCustomDisplayProperties=Txt 'txtWordCustomDisplayProperties';WordDocumentFormat=Sel 'cbWordDocumentFormat';WordDocumentationLevel=Sel 'cbWordDocumentationLevel';WordDocumentationLimitMaxLength=Txt 'txtWordDocumentationLimitMaxLength';WordDocumentationLimitTruncateLength=Txt 'txtWordDocumentationLimitTruncateLength';WordDocumentationLimitAttach=Chk 'chkWordDocumentationLimitAttach';WordAddCategories=Chk 'chkWordAddCategories';WordAddSubCategories=Chk 'chkWordAddSubCategories';WordOpenDocument=Chk 'chkWordOpenDocument';WordAttachJsonFile=Chk 'chkWordAttachJsonFile';WordTitleProperty=Txt 'txtWordTitleProperty';WordSubjectProperty=Txt 'txtWordSubjectProperty';WordContentControls=Txt 'txtWordContentControls';WordCoverPage=Txt 'txtWordCoverPage';WordHeader1Style=Txt 'txtWordHeader1Style';WordHeader2Style=Txt 'txtWordHeader2Style';WordHeader3Style=Txt 'txtWordHeader3Style';WordTableStyle=Txt 'txtWordTableStyle';WordTableHeaderStyle=Txt 'txtWordTableHeaderStyle';WordCategoryHeaderStyle=Txt 'txtWordCategoryHeaderStyle';WordSubCategoryHeaderStyle=Txt 'txtWordSubCategoryHeaderStyle';WordTableTextStyle=Txt 'txtWordTableTextStyle';WordTableCaptionPosition=Sel 'cbWordTableCaptionPosition';WordScriptTableStyle=Txt 'txtWordScriptTableStyle';WordScriptStyle=Txt 'txtWordScriptStyle'}
}
}
}
function Add-WpfDocumentationFileBrowse {
param($Form, [string]$ButtonName, [string]$TextName, [switch]$Save, [string]$Filter = 'All files (*.*)|*.*')
$script:UIProvider.AddXamlEvent($Form, $ButtonName, 'add_click', {
$dialog = if($Save) { [System.Windows.Forms.SaveFileDialog]::new() } else { [System.Windows.Forms.OpenFileDialog]::new() }
$dialog.Filter = $Filter
$current = [string]$Form.FindName($TextName).Text
if($current) { $dialog.FileName = $current }
if($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$Form.FindName($TextName).Text = $dialog.FileName
}
}.GetNewClosure())
}
function Set-WpfDocumentationOutputPanel {
param($Form, [string]$OutputValue)
foreach($output in [DocumentationRegistry]::Outputs) {
$panel = $Form.FindName("pnlDocumentationOutput$($output.Name)")
if($panel) { $panel.Visibility = if($output.Value -eq $OutputValue) { 'Visible' } else { 'Collapsed' } }
}
}
function Show-GraphBulkDocumentationForm
{
param([object[]]$PolicyObject)
$script:bulkDocForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkDocumentationForm.xaml"), $true)
if(-not $script:bulkDocForm) { return }
$script:dgObjectsToDocument = $script:bulkDocForm.FindName("dgObjectsToDocument")
# ---- Output-format combo populated from the registry ----
$cbType = $script:bulkDocForm.FindName("cbDocumentationType")
if($cbType) {
$outputs = @([DocumentationRegistry]::Outputs |
Sort-Object Name |
ForEach-Object { [PSCustomObject]@{ Name = $_.Name; Value = $_.Value } })
$cbType.ItemsSource = $outputs
if($outputs.Count -gt 0) {
$last = Get-SettingStoreValue "Documentation" "OutputType" "json"
$match = $outputs | Where-Object Value -eq $last | Select-Object -First 1
if($match) { $cbType.SelectedValue = $match.Value }
else { $cbType.SelectedIndex = 0 }
}
Set-WpfDocumentationOutputPanel $script:bulkDocForm ([string]$cbType.SelectedValue)
$cbType.Add_SelectionChanged({
param($source, $e)
Set-WpfDocumentationOutputPanel $script:bulkDocForm ([string]$source.SelectedValue)
})
}
# ---- Restore persisted form values ----
$script:UIProvider.SetXamlProperty($script:bulkDocForm, "txtDocumentationOutputFolder", "Text", [string](Get-SettingStoreValue "Documentation" "OutputFolder" ""))
$script:UIProvider.SetXamlProperty($script:bulkDocForm, "txtDocumentFromFolder", "Text", [string](Get-DocumentationSetting 'SourceFolder' ''))
$script:UIProvider.SetXamlProperty($script:bulkDocForm, "txtDocumentFilter", "Text", [string](Get-DocumentationSetting 'NameFilter' ''))
Initialize-WpfDocumentationOptions $script:bulkDocForm
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "browseDocumentationOutputFolder", "add_click", {
$current = $script:UIProvider.GetXamlProperty($script:bulkDocForm, "txtDocumentationOutputFolder", "Text")
$folder = Get-Folder $current "Select root folder for documentation"
if($folder) {
$script:UIProvider.SetXamlProperty($script:bulkDocForm, "txtDocumentationOutputFolder", "Text", $folder)
}
})
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "browseDocumentFromFolder", "add_click", {
$current = $script:UIProvider.GetXamlProperty($script:bulkDocForm, "txtDocumentFromFolder", "Text")
$folder = Get-Folder $current "Select exported source folder"
if($folder) {
$script:UIProvider.SetXamlProperty($script:bulkDocForm, "txtDocumentFromFolder", "Text", $folder)
}
})
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "browseCSVDocumentationPath", "add_click", {
$current = $script:UIProvider.GetXamlProperty($script:bulkDocForm, "txtCSVDocumentationPath", "Text")
$folder = Get-Folder $current "Select CSV documentation folder"
if($folder) { $script:UIProvider.SetXamlProperty($script:bulkDocForm, "txtCSVDocumentationPath", "Text", $folder) }
})
# Registry-driven browse-button wiring. Each output provider that has
# file-picker inputs on the bulk-doc form attaches a FileBrowseControls
# array to its [DocumentationRegistry] entry from a matching WPF
# ClassExtensions file (UI/WPF/ClassExtensions/DocumentationOutput<X>UIExtension.ps1).
# Adding a new provider with browse buttons requires no edit here — just
# drop a ClassExtensions file that Add-Member's onto the provider.
foreach($output in [DocumentationRegistry]::Outputs) {
if(-not $output.PSObject.Properties['FileBrowseControls']) { continue }
foreach($fb in @($output.FileBrowseControls)) {
$params = @{ Form = $script:bulkDocForm; ButtonName = $fb.Button; TextName = $fb.TextBox }
if($fb.Save) { $params['Save'] = $true }
if($fb.Filter) { $params['Filter'] = $fb.Filter }
Add-WpfDocumentationFileBrowse @params
}
}
# ---- Object list (same filter as Bulk Export) ----
$script:bulkDocDocumentableGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and (-not ($_.ShowButtons -is [Object[]]) -or $_.ShowButtons -contains "Document")
})
$script:bulkDocPolicyObjects = Get-UIItemsArray $PolicyObject
# Selected column with header-checkbox toggle-all
$column = Get-GridCheckboxColumn "Selected"
$script:dgObjectsToDocument.Columns.Add($column)
$column.Header.IsChecked = $true
$column.Header.add_Click({
foreach($item in $script:dgObjectsToDocument.ItemsSource) {
$item.Selected = $this.IsChecked
}
$script:dgObjectsToDocument.Items.Refresh()
})
# Title column
$binding = [System.Windows.Data.Binding]::new("Title")
$titleCol = [System.Windows.Controls.DataGridTextColumn]::new()
$titleCol.Header = if($script:bulkDocPolicyObjects.Count -gt 0) { "Title" } else { "Object type" }
$titleCol.IsReadOnly = $true
$titleCol.Binding = $binding
$script:dgObjectsToDocument.Columns.Add($titleCol)
$script:bulkDocMode = if($script:bulkDocPolicyObjects.Count -gt 0) { "Object" } else { "Group" }
$listBy = $script:bulkDocForm.FindName("pnlDocumentationListBy")
if($listBy) { $listBy.Visibility = if($script:bulkDocMode -eq "Object") { "Collapsed" } else { "Visible" } }
foreach($bulkOnlyControlName in @("lblDocumentFilter","txtDocumentFilter","lblDocumentFromFolder","pnlDocumentFromFolder")) {
$bulkOnlyControl = $script:bulkDocForm.FindName($bulkOnlyControlName)
if($bulkOnlyControl) { $bulkOnlyControl.Visibility = if($script:bulkDocMode -eq "Object") { "Collapsed" } else { "Visible" } }
}
Update-BulkDocumentationObjectList
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "rbBulkDocViewGroup", "add_Checked", {
$script:bulkDocMode = "Group"
Update-BulkDocumentationObjectList
})
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "rbBulkDocViewType", "add_Checked", {
$script:bulkDocMode = "Type"
Update-BulkDocumentationObjectList
})
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "btnClose", "add_click", {
$script:bulkDocForm = $null
$script:bulkDocPolicyObjects = $null
Show-ModalObject
})
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "btnDocument", "add_click", {
try {
Write-Status "Initializing documentation"
$selection = if($script:bulkDocMode -eq "Object") { Get-BulkDocumentationSelectedObjects } else { Get-BulkDocumentationSelectedIds }
$unit = if($script:bulkDocMode -eq "Object") { "policy" } elseif($script:bulkDocMode -eq "Type") { "policy type" } else { "object group" }
if($selection.Count -eq 0) {
$script:UIProvider.ShowMessageBox("Select at least one $unit to document.", "Bulk Documentation", "OK", "Warning") | Out-Null
return
}
$cb = $script:bulkDocForm.FindName("cbDocumentationType")
$outputValue = if($cb) { [string]$cb.SelectedValue } else { $null }
if(-not $outputValue) {
$script:UIProvider.ShowMessageBox("Select an output format.", "Bulk Documentation", "OK", "Warning") | Out-Null
return
}
$rawFolder = $script:UIProvider.GetXamlProperty($script:bulkDocForm, "txtDocumentationOutputFolder", "Text")
if($rawFolder) { $rawFolder = ([string]$rawFolder).Trim().Trim('"').Trim("'") }
$options = Get-WpfDocumentationOptions $script:bulkDocForm
if(-not $rawFolder) {
# Registry-driven fallback: consult the provider's declared
# PrimaryPathOption to find its persisted path, and PathIsFolder to
# decide whether the persisted value is already a folder (CSV) or a
# file whose parent we need to take. Adding a provider requires no
# change here — it just declares the two fields on registration.
$provider = [DocumentationRegistry]::FindOutput($outputValue)
$selectedPath = if($provider -and $provider.PSObject.Properties['PrimaryPathOption'] -and $provider.PrimaryPathOption) {
$providerOpts = $options.Outputs[$outputValue]
if($providerOpts) { $providerOpts[$provider.PrimaryPathOption] }
}
if($selectedPath) {
$rawFolder = if($provider -and $provider.PSObject.Properties['PathIsFolder'] -and $provider.PathIsFolder) { $selectedPath }
else { Split-Path -Parent $selectedPath }
}
if(-not $rawFolder) { $rawFolder = [Environment]::GetFolderPath('MyDocuments') }
}
try { $resolvedFolder = [IO.Path]::GetFullPath($rawFolder) }
catch {
$script:UIProvider.ShowMessageBox("Invalid output folder path: $($_.Exception.Message)", "Bulk Documentation", "OK", "Error") | Out-Null
return
}
$language = [string]$options.Language
if(-not $language) { $language = 'en' }
$defaultName = "%Organization%-%Date%"
if(-not $options.Outputs.csv.CSVDocumentationPath) { $options.Outputs.csv.CSVDocumentationPath = $resolvedFolder }
if(-not $options.Outputs.json.JSONDocumentName) { $options.Outputs.json.JSONDocumentName = Join-Path $resolvedFolder "$defaultName.json" }
if(-not $options.Outputs.html.HTMLDocumentName) { $options.Outputs.html.HTMLDocumentName = Join-Path $resolvedFolder "$defaultName.html" }
# Confluence storage format is XHTML, so .html keeps it openable in a browser.
if(-not $options.Outputs.atlassian.AtlassianDocumentName) { $options.Outputs.atlassian.AtlassianDocumentName = Join-Path $resolvedFolder "$defaultName.html" }
if(-not $options.Outputs.md.MDDocumentName) { $options.Outputs.md.MDDocumentName = Join-Path $resolvedFolder "$defaultName.md" }
if(-not $options.Outputs.word.WordDocumentName) { $options.Outputs.word.WordDocumentName = Join-Path $resolvedFolder "$defaultName.docx" }
# Persist form state for next time
Save-SettingStoreValue "Documentation" "OutputType" $outputValue
Save-SettingStoreValue "Documentation" "OutputFolder" $resolvedFolder
Save-DocumentationOptionsDefaults $options
$startParams = @{
OutputFormat = $outputValue
Language = $language
Options = $options
}
if($script:bulkDocMode -ne "Object" -and $options.SourceFolder) { $startParams.SourceFolder = $options.SourceFolder }
if($script:bulkDocMode -eq "Type") {
$startParams.PolicyType = $selection
}
elseif($script:bulkDocMode -eq "Group") {
$startParams.PolicyGroup = $selection
}
Write-Log "Documentation starting. Mode=$script:bulkDocMode Format=$outputValue Folder=$resolvedFolder Selection=$($selection -join ',')"
try {
if($script:bulkDocMode -eq "Object") {
[void]($selection | Start-GraphBulkDocumentation @startParams)
}
else {
[void](Start-GraphBulkDocumentation @startParams)
}
Write-Status $null
$script:UIProvider.ShowMessageBox(("Documentation finished. Output folder:`n{0}" -f $resolvedFolder), "Bulk Documentation", "OK", "Information") | Out-Null
}
catch {
Write-LogError "Bulk documentation failed" $_.Exception
$script:UIProvider.ShowMessageBox("Bulk documentation failed: $($_.Exception.Message)", "Bulk Documentation", "OK", "Error") | Out-Null
}
}
finally {
Write-Status $null
}
})
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "btnPreviewDocumentation", "add_click", {
$selection = if($script:bulkDocMode -eq "Object") { Get-BulkDocumentationSelectedObjects } else { Get-BulkDocumentationSelectedIds }
if($selection.Count -eq 0) { return }
$options = Get-WpfDocumentationOptions $script:bulkDocForm
$params = if($script:bulkDocMode -eq 'Type') { @{PolicyType=$selection} } elseif($script:bulkDocMode -eq 'Group') { @{PolicyGroup=$selection} } else { @{} }
if($script:bulkDocMode -eq 'Object') {
$policies = $selection
}
elseif($options.SourceFolder) {
$options.SourceTenantUnavailable = $true
Initialize-DocumentationSourceTenantContext -SourceFolder $options.SourceFolder
$policies = Get-DocumentationPoliciesFromSourceFolder -SourceFolder $options.SourceFolder @params
} else {
$policies = Get-GraphPolicies @params
}
$script:bulkDocPreview = @($policies | Select-Object -First 5 | Get-GraphDocumentation -Language $options.Language -Options $options)
$script:bulkDocForm.FindName('txtDocumentationRawData').Text = ($script:bulkDocPreview | ConvertTo-Json -Depth 10)
})
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "btnCopyBasicDocumentation", "add_click", {
@($script:bulkDocPreview | ForEach-Object BasicInfo) | Select-Object Name,Value | ConvertTo-Csv -NoTypeInformation | Set-Clipboard
})
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "btnCopySettingsDocumentation", "add_click", {
@($script:bulkDocPreview | ForEach-Object FilteredSettings) | ConvertTo-Csv -NoTypeInformation | Set-Clipboard
})
$script:UIProvider.AddXamlEvent($script:bulkDocForm, "btnCopyRawDocumentation", "add_click", {
$script:bulkDocForm.FindName('txtDocumentationRawData').Text | Set-Clipboard
})
$title = if($script:bulkDocMode -eq 'Object') { 'Document' } else { 'Bulk Documentation' }
$script:UIProvider.ShowModalForm($title, $script:bulkDocForm, $true)
}
function Update-BulkDocumentationObjectList
{
if(-not $script:dgObjectsToDocument) { return }
$script:documentObjects = @()
if($script:bulkDocMode -eq "Object") {
foreach($policy in ($script:bulkDocPolicyObjects | Sort-Object Name)) {
$script:documentObjects += New-Object PSObject -Property @{
Title = $policy.Name
Selected = $true
ObjectGroup = $null
ObjectType = $policy.PolicyType
PolicyObject = $policy
}
}
}
elseif($script:bulkDocMode -eq "Type") {
$documentableGroupIds = @($script:bulkDocDocumentableGroups | ForEach-Object { $_.Id })
$sortedTypes = $script:IntuneTypes |
Where-Object { $_.PolicyGroup -and ($documentableGroupIds -contains $_.PolicyGroup.Id) } |
Sort-Object Title
foreach($intuneType in $sortedTypes) {
$script:documentObjects += New-Object PSObject -Property @{
Title = $intuneType.Title
Selected = $true
ObjectGroup = $null
ObjectType = $intuneType
}
}
}
else {
$sortedGroups = $script:bulkDocDocumentableGroups | Sort-Object Title
foreach($intuneGroup in $sortedGroups) {
$script:documentObjects += New-Object PSObject -Property @{
Title = $intuneGroup.Title
Selected = $true
ObjectGroup = $intuneGroup
ObjectType = $null
}
}
}
$script:dgObjectsToDocument.ItemsSource = $script:documentObjects
}
function Get-BulkDocumentationSelectedIds
{
if($script:bulkDocMode -eq "Type") {
return @($script:documentObjects |
Where-Object { $_.Selected -eq $true -and $_.ObjectType -and $_.ObjectType.Id } |
ForEach-Object { $_.ObjectType.Id })
}
return @($script:documentObjects |
Where-Object { $_.Selected -eq $true -and $_.ObjectGroup -and $_.ObjectGroup.Id } |
ForEach-Object { $_.ObjectGroup.Id })
}
function Get-BulkDocumentationSelectedObjects
{
return @($script:documentObjects |
Where-Object { $_.Selected -eq $true -and $_.PolicyObject } |
ForEach-Object { $_.PolicyObject })
}
# Compatibility wrapper for the main-view Document button. The shared form
# switches to Object mode and displays the selected policies.
function Show-IntuneManagerDocumentForm
{
param([object[]]$PolicyObject)
if (-not $PolicyObject -or $PolicyObject.Count -eq 0) {
$script:UIProvider.ShowMessageBox("No items to document.", "Document", "OK", "Warning") | Out-Null
return
}
Show-GraphBulkDocumentationForm -PolicyObject $PolicyObject
}
#endregion
#endregion
@@ -0,0 +1,210 @@
# Bulk Export form.
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Show-GraphBulkExportForm
{
$script:bulkExportForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkExportForm.xaml"), $true)
if(-not $script:bulkExportForm) { return }
$script:dgObjectsToExport = $script:bulkExportForm.FindName("dgObjectsToExport")
$exportSettings = [IntuneManagerExportSettings]::new()
$script:bulkExportForm.DataContext = $exportSettings
$script:UIProvider.AddXamlEvent($script:bulkExportForm, "browseExportPath", "add_click", {
$folder = Get-Folder ($script:UIProvider.GetXamlProperty($script:bulkExportForm, "txtExportPath", "Text")) "Select root folder for export"
if($folder)
{
$script:bulkExportForm.DataContext.ExportFolder = $folder
$tmp = $script:bulkExportForm.DataContext
$script:bulkExportForm.DataContext = $null
$script:bulkExportForm.DataContext = $tmp
}
})
# Register dynamic export-properties / UI extensions for every exportable
# policy type up front. Done once (not per mode switch) so toggling
# Group/API doesn't re-register and we never miss a type that only appears
# in the other view.
$script:bulkExportExportableGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and (-not ($_.ShowButtons -is [Object[]]) -or $_.ShowButtons -contains "Export")
})
foreach($intuneGroup in $script:bulkExportExportableGroups)
{
$intuneGroup.PolicyTypes | Add-IntuneManagerExportProperties -ExportSettings $exportSettings
$intuneGroup.PolicyTypes | Add-IntuneManagerExportUIExtensions -Form $script:bulkExportForm
}
$column = Get-GridCheckboxColumn "Selected"
$script:dgObjectsToExport.Columns.Add($column)
$column.Header.IsChecked = $true # All items are checked by default
$column.Header.add_Click({
foreach($Item in $script:dgObjectsToExport.ItemsSource)
{
$Item.Selected = $this.IsChecked
}
$script:dgObjectsToExport.Items.Refresh()
}
)
# Add Object type column
$binding = [System.Windows.Data.Binding]::new("Title")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "Object type"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:dgObjectsToExport.Columns.Add($column)
# Default view is "Group" (matches the IsChecked=True on the XAML radio).
$script:bulkExportMode = "Group"
Update-BulkExportObjectList
# Repopulate the grid when the user flips between Group and API views.
$script:UIProvider.AddXamlEvent($script:bulkExportForm, "rbBulkExportViewGroup", "add_Checked", {
$script:bulkExportMode = "Group"
Update-BulkExportObjectList
})
$script:UIProvider.AddXamlEvent($script:bulkExportForm, "rbBulkExportViewType", "add_Checked", {
$script:bulkExportMode = "Type"
Update-BulkExportObjectList
})
$script:UIProvider.AddXamlEvent($script:bulkExportForm, "btnClose", "add_click", {
$script:bulkExportForm = $null
Show-ModalObject
})
# NOTE: the "Save settings for batch job" button was removed 2026-06-12 per
# user decision — scheduled/CI runs use the public drivers directly
# (Start-GraphBulkExport -SettingsFile files can still be produced with
# Save-GraphBulkExportSettings from a script).
# btnExport: collect the selected rows from the DataGrid and call the public
# driver. The UI does NOT contain export logic — it's a thin caller of
# Start-GraphBulkExport so the same operation is scriptable headlessly.
$script:UIProvider.AddXamlEvent($script:bulkExportForm, "btnExport", "add_click", {
$selection = Get-BulkExportSelectedIds
$unit = if ($script:bulkExportMode -eq "Type") { "policy type" } else { "object group" }
if ($selection.Count -eq 0) {
$script:UIProvider.ShowMessageBox("Select at least one $unit to export.", "Bulk Export", "OK", "Warning") | Out-Null
return
}
# Read the path straight from the TextBox rather than relying on the binding —
# the field is bound TwoWay/PropertyChanged so they should agree, but pulling
# from the control directly is bullet-proof. Trim and unquote (users sometimes
# paste paths with surrounding quotes).
$rawPath = ($script:UIProvider.GetXamlProperty($script:bulkExportForm, "txtExportPath", "Text"))
if ($rawPath) { $rawPath = ([string]$rawPath).Trim().Trim('"').Trim("'") }
if (-not $rawPath) {
$script:UIProvider.ShowMessageBox("Select an export folder.", "Bulk Export", "OK", "Warning") | Out-Null
return
}
# Resolve to a full path so the success message shows the canonical location,
# not whatever relative-ish text the user typed.
try { $resolvedPath = [IO.Path]::GetFullPath($rawPath) }
catch {
$script:UIProvider.ShowMessageBox("Invalid export folder path: $($_.Exception.Message)", "Bulk Export", "OK", "Error") | Out-Null
return
}
$script:bulkExportForm.DataContext.ExportFolder = $resolvedPath
Write-Log "Bulk export starting. Folder: $resolvedPath"
$startParams = @{
ExportSettings = $script:bulkExportForm.DataContext
}
if ($script:bulkExportMode -eq "Type") {
$startParams.PolicyType = $selection
}
else {
$startParams.PolicyGroup = $selection
}
try {
$summary = Start-GraphBulkExport @startParams
Write-Status $null
$msg = ("Exported {0} policies across {1} types ({2} failed) in {3:hh\:mm\:ss}.`n`nFolder:`n{4}" -f `
$summary.Policies, $summary.Types, $summary.Failed, $summary.Duration, $resolvedPath)
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if($unknownNote) { $msg += "`n`n$unknownNote" }
$script:UIProvider.ShowMessageBox($msg, "Bulk Export", "OK", "Information") | Out-Null
}
catch {
Write-LogError "Bulk export failed" $_.Exception
$script:UIProvider.ShowMessageBox("Bulk export failed: $($_.Exception.Message)", "Bulk Export", "OK", "Error") | Out-Null
}
$script:bulkExportForm = $null
Show-ModalObject
})
$script:UIProvider.ShowModalForm("Bulk Export", $script:bulkExportForm, $true)
}
# Rebuild the bulk-export DataGrid for the current $script:bulkExportMode.
# Group mode lists policy groups (Configuration, Compliance, ...); Type mode
# lists individual policy types (APIs). Both sort by Title so the order is
# stable instead of class-load order.
function Update-BulkExportObjectList
{
if (-not $script:dgObjectsToExport) { return }
$script:exportObjects = @()
if ($script:bulkExportMode -eq "Type") {
$exportableGroupIds = @($script:bulkExportExportableGroups | ForEach-Object { $_.Id })
$sortedTypes = $script:IntuneTypes |
Where-Object { $_.PolicyGroup -and ($exportableGroupIds -contains $_.PolicyGroup.Id) } |
Sort-Object Title
foreach ($intuneType in $sortedTypes) {
$script:exportObjects += New-Object PSObject -Property @{
Title = $intuneType.Title
Selected = $true
ObjectGroup = $null
ObjectType = $intuneType
}
}
}
else {
$sortedGroups = $script:bulkExportExportableGroups | Sort-Object Title
foreach ($intuneGroup in $sortedGroups) {
$script:exportObjects += New-Object PSObject -Property @{
Title = $intuneGroup.Title
Selected = (?? $intuneGroup.BulkExport $true)
ObjectGroup = $intuneGroup
ObjectType = $null
}
}
}
$script:dgObjectsToExport.ItemsSource = $script:exportObjects
}
# Pull the Id list out of the currently-checked rows. In Group mode that's
# PolicyGroup IDs (for -PolicyGroup); in Type mode it's PolicyType IDs (for
# -PolicyType). Returns @() when nothing is selected.
function Get-BulkExportSelectedIds
{
if ($script:bulkExportMode -eq "Type") {
return @($script:exportObjects |
Where-Object { $_.Selected -eq $true -and $_.ObjectType -and $_.ObjectType.Id } |
ForEach-Object { $_.ObjectType.Id })
}
return @($script:exportObjects |
Where-Object { $_.Selected -eq $true -and $_.ObjectGroup -and $_.ObjectGroup.Id } |
ForEach-Object { $_.ObjectGroup.Id })
}
# ─── Bulk Scope Tags ──────────────────────────────────────────────────────────
# Thin UI wrapper around Set-GraphBulkScopeTags. The form mirrors Bulk Export's
# Group / API selector for picking object types, adds an action selector
# (Add / Replace / Remove), an orphan-cleanup checkbox, and a dual-list scope
# tag picker. All business logic lives in the public command — the UI only
# collects inputs, calls it, and shows the summary.
# Get-BulkScopeTagCatalog moved to Internal/IntuneScopeTags.ps1 so the
# Avalonia tree can share it.
@@ -0,0 +1,145 @@
# Bulk Import form.
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Show-GraphBulkImportForm
{
$script:bulkImportForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkImportForm.xaml"), $true)
if(-not $script:bulkImportForm) { return }
$script:dgObjectsToImport = $script:bulkImportForm.FindName("dgObjectsToImport")
$importSettings = [IntuneManagerImportSettings]::new()
$script:bulkImportForm.DataContext = $importSettings
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "txtImportPath", "Text", $importSettings.ImportFolder)
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "chkImportAssignments", "IsChecked", $importSettings.ImportAssignments)
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "chkImportScopes", "IsChecked", $importSettings.ImportScopeTags)
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "chkReplaceDependencyIDs", "IsChecked", $importSettings.ReplaceDependencyIDs)
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "cbImportType", "ItemsSource", $script:IntuneManagerImportOptions)
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "cbImportType", "SelectedValue", $importSettings.ImportType)
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "lblImportType", "Visibility", "Visible")
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "cbImportType", "Visibility", "Visible")
$script:importObjects = @()
foreach($intuneGroup in $script:IntuneGroups)
{
if(-not $intuneGroup.Title) { continue }
if($intuneGroup.ShowButtons -is [Object[]] -and $intuneGroup.ShowButtons -notcontains "Import") { continue }
$script:importObjects += New-Object PSObject -Property @{
Title = $intuneGroup.Title
Selected = (?? $intuneGroup.BulkImport $true)
ObjectGroup = $intuneGroup
}
$intuneGroup.PolicyTypes | Add-IntuneManagerImportUIExtensions -Form $script:bulkImportForm
}
$column = Get-GridCheckboxColumn "Selected"
$script:dgObjectsToImport.Columns.Add($column)
$column.Header.IsChecked = $true
$column.Header.add_Click({
foreach($Item in $script:dgObjectsToImport.ItemsSource)
{
$Item.Selected = $this.IsChecked
}
$script:dgObjectsToImport.Items.Refresh()
})
$binding = [System.Windows.Data.Binding]::new("Title")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "Object type"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:dgObjectsToImport.Columns.Add($column)
$script:dgObjectsToImport.ItemsSource = $script:importObjects
if($importSettings.ImportFolder)
{
$migrationInfo, $sameTenant = Get-MigrationTableInfo $importSettings.ImportFolder $script:OrganizationId
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "chkReplaceDependencyIDs", "IsEnabled", ($sameTenant -eq $false))
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "chkReplaceDependencyIDs", "IsChecked", ($sameTenant -eq $false))
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "lblMigrationTableInfo", "Content", $migrationInfo)
}
$script:UIProvider.AddXamlEvent($script:bulkImportForm, "browseImportPath", "add_click", {
$folder = Get-Folder ($script:UIProvider.GetXamlProperty($script:bulkImportForm, "txtImportPath", "Text")) "Select root folder for import"
if($folder)
{
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "txtImportPath", "Text", $folder)
$script:bulkImportForm.DataContext.ImportFolder = $folder
$migrationInfo, $sameTenant = Get-MigrationTableInfo $folder $script:OrganizationId
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "chkReplaceDependencyIDs", "IsEnabled", ($sameTenant -eq $false))
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "chkReplaceDependencyIDs", "IsChecked", ($sameTenant -eq $false))
$script:UIProvider.SetXamlProperty($script:bulkImportForm, "lblMigrationTableInfo", "Content", $migrationInfo)
}
})
$script:UIProvider.AddXamlEvent($script:bulkImportForm, "btnClose", "add_click", {
$script:bulkImportForm = $null
Show-ModalObject
})
$script:UIProvider.AddXamlEvent($script:bulkImportForm, "btnImport", "add_click", {
$importFolder = Expand-FileName ($script:UIProvider.GetXamlProperty($script:bulkImportForm, "txtImportPath", "Text"))
if(-not [IO.Directory]::Exists($importFolder))
{
$script:UIProvider.ShowMessageBox("Import folder not found:`n$importFolder", "Error", "OK", "Error")
return
}
$selectedGroups = @($script:importObjects | Where-Object Selected -eq $true)
if($selectedGroups.Count -eq 0)
{
$script:UIProvider.ShowMessageBox("No object types selected.", "Error", "OK", "Error")
return
}
Save-SettingStoreValue "" "LastUsedRoot" $importFolder
$importType = $script:UIProvider.GetXamlProperty($script:bulkImportForm, "cbImportType", "SelectedValue")
$nameFilter = ($script:UIProvider.GetXamlProperty($script:bulkImportForm, "txtImportNameFilter", "Text")).Trim()
# The UI is a thin caller of the public driver (R10) — the same import
# runs headless via Start-GraphBulkImport, which also persists the
# checkbox settings (the import pipeline reads them via Get-SettingValue)
# and honours the ClearCacheBeforeExportImport setting.
Write-Status "Bulk import" -Block
try
{
$summary = Start-GraphBulkImport -ImportFolder $importFolder -Filter $nameFilter `
-PolicyGroup @($selectedGroups | ForEach-Object { $_.ObjectGroup.ID }) `
-ImportAssignments ([bool]($script:UIProvider.GetXamlProperty($script:bulkImportForm, "chkImportAssignments", "IsChecked"))) `
-ImportScopeTags ([bool]($script:UIProvider.GetXamlProperty($script:bulkImportForm, "chkImportScopes", "IsChecked"))) `
-ReplaceDependencyIDs ([bool]($script:UIProvider.GetXamlProperty($script:bulkImportForm, "chkReplaceDependencyIDs", "IsChecked"))) `
-ImportType $importType
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if($summary.Imported -eq 0)
{
$msg = "No objects were imported.`nVerify the import folder and exported files."
if($unknownNote) { $msg += "`n`n$unknownNote" }
$script:UIProvider.ShowMessageBox($msg, "Import", "OK", "Warning")
}
else
{
# Success shows no dialog, so a group id that matched nothing gets its own.
if($unknownNote) { $script:UIProvider.ShowMessageBox($unknownNote, "Import", "OK", "Warning") | Out-Null }
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
}
}
catch
{
Write-LogError "Bulk import failed" $_.Exception
$script:UIProvider.ShowMessageBox("Bulk import failed:`n`n$($_.Exception.Message)", "Import", "OK", "Error") | Out-Null
}
Write-Status ""
})
$script:UIProvider.ShowModalForm("Bulk Import", $script:bulkImportForm, $true)
}
@@ -0,0 +1,278 @@
# Bulk Scope Tag form.
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Show-GraphBulkScopeTagForm
{
$script:bulkScopeTagForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\BulkScopeTagForm.xaml"))
if(-not $script:bulkScopeTagForm) { return }
$script:dgBulkScopeTagObjects = $script:bulkScopeTagForm.FindName("dgBulkScopeTagObjects")
$script:lstBulkScopeTagAvail = $script:bulkScopeTagForm.FindName("lstBulkScopeTagAvailable")
$script:lstBulkScopeTagSelected = $script:bulkScopeTagForm.FindName("lstBulkScopeTagSelected")
$script:txtBulkScopeTagStatus = $script:bulkScopeTagForm.FindName("txtBulkScopeTagStatus")
$scopeTagSettings = [IntuneManagerScopeTagSettings]::new()
$script:bulkScopeTagForm.DataContext = $scopeTagSettings
# ── Object list (mirrors Bulk Export DataGrid setup) ──────────────────────
$script:bulkScopeTagEligibleTypes = @($script:IntuneTypes | Where-Object { $_.ScopeTagProperty })
$script:bulkScopeTagEligibleGroups = @($script:IntuneGroups | Where-Object {
$_.Title -and ($_.PolicyTypes | Where-Object { $_.ScopeTagProperty })
})
$column = Get-GridCheckboxColumn "Selected"
$script:dgBulkScopeTagObjects.Columns.Add($column)
$column.Header.IsChecked = $true
$column.Header.add_Click({
foreach($item in $script:dgBulkScopeTagObjects.ItemsSource) {
$item.Selected = $this.IsChecked
}
$script:dgBulkScopeTagObjects.Items.Refresh()
})
$col = [System.Windows.Controls.DataGridTextColumn]::new()
$col.Header = "Object type"
$col.IsReadOnly = $true
$col.Binding = [System.Windows.Data.Binding]::new("Title")
$script:dgBulkScopeTagObjects.Columns.Add($col)
$script:bulkScopeTagMode = "Group"
Update-BulkScopeTagObjectList
$script:UIProvider.AddXamlEvent($script:bulkScopeTagForm, "rbBulkScopeTagViewGroup", "add_Checked", {
$script:bulkScopeTagMode = "Group"; Update-BulkScopeTagObjectList
})
$script:UIProvider.AddXamlEvent($script:bulkScopeTagForm, "rbBulkScopeTagViewType", "add_Checked", {
$script:bulkScopeTagMode = "Type"; Update-BulkScopeTagObjectList
})
# ── Action radio → settings.Action sync ───────────────────────────────────
# XAML radio buttons aren't directly bound to a string property; wire change
# handlers so the [IntuneManagerScopeTagSettings] always reflects the UI.
$script:UIProvider.AddXamlEvent($script:bulkScopeTagForm, "rbBulkScopeTagAdd", "add_Checked", { $script:bulkScopeTagForm.DataContext.Action = "Add" })
$script:UIProvider.AddXamlEvent($script:bulkScopeTagForm, "rbBulkScopeTagReplace", "add_Checked", { $script:bulkScopeTagForm.DataContext.Action = "Replace" })
$script:UIProvider.AddXamlEvent($script:bulkScopeTagForm, "rbBulkScopeTagRemove", "add_Checked", { $script:bulkScopeTagForm.DataContext.Action = "Remove" })
# ── Scope tag picker (dual-list with click-to-move) ───────────────────────
# Keep the modal open path cheap. The tenant dependency cache is used first;
# if it is cold, the live Graph fetch runs after the dialog has rendered.
$script:_bulkScopeTagAll = @()
$script:_bulkScopeTagSelectedIds = [System.Collections.Generic.HashSet[string]]::new()
$script:colBulkScopeTagAvail = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
$script:colBulkScopeTagSelected = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
$script:_syncBulkScopeTagLists = {
$script:colBulkScopeTagAvail.Clear()
$script:colBulkScopeTagSelected.Clear()
foreach($t in $script:_bulkScopeTagAll) {
if($script:_bulkScopeTagSelectedIds.Contains($t.Id)) {
[void]$script:colBulkScopeTagSelected.Add($t)
} else {
[void]$script:colBulkScopeTagAvail.Add($t)
}
}
}
& $script:_syncBulkScopeTagLists
$script:lstBulkScopeTagAvail.ItemsSource = $script:colBulkScopeTagAvail
$script:lstBulkScopeTagSelected.ItemsSource = $script:colBulkScopeTagSelected
$script:txtBulkScopeTagStatus.Text = "Loading scope tags..."
# Double-click toggles list membership in either direction; the Add/Remove
# buttons do the same for the highlighted rows (double-click alone was not
# discoverable).
$script:_bulkScopeTagAddSelected = {
$ids = @($script:lstBulkScopeTagAvail.SelectedItems | ForEach-Object { [string]$_.Id })
foreach($id in $ids) { [void]$script:_bulkScopeTagSelectedIds.Add($id) }
& $script:_syncBulkScopeTagLists
}
$script:_bulkScopeTagRemoveSelected = {
$ids = @($script:lstBulkScopeTagSelected.SelectedItems | ForEach-Object { [string]$_.Id })
foreach($id in $ids) { [void]$script:_bulkScopeTagSelectedIds.Remove($id) }
& $script:_syncBulkScopeTagLists
}
$script:lstBulkScopeTagAvail.Add_MouseDoubleClick({ & $script:_bulkScopeTagAddSelected })
$script:lstBulkScopeTagSelected.Add_MouseDoubleClick({ & $script:_bulkScopeTagRemoveSelected })
$script:UIProvider.AddXamlEvent($script:bulkScopeTagForm, "btnBulkScopeTagAddTag", "add_click", { & $script:_bulkScopeTagAddSelected })
$script:UIProvider.AddXamlEvent($script:bulkScopeTagForm, "btnBulkScopeTagRemoveTag", "add_click", { & $script:_bulkScopeTagRemoveSelected })
# ── Apply ─────────────────────────────────────────────────────────────────
$script:UIProvider.AddXamlEvent($script:bulkScopeTagForm, "btnBulkScopeTagApply", "add_click", {
# Concurrent-click guard. Apply triggers a multi-step Graph run; without
# this, a double-click (or impatient second press) fires two parallel
# bulk PATCH passes against the same selection.
$btnApply = $script:bulkScopeTagForm.FindName("btnBulkScopeTagApply")
$btnClose = $script:bulkScopeTagForm.FindName("btnBulkScopeTagClose")
$selectionIds = Get-BulkScopeTagSelectedObjectIds
$unit = if($script:bulkScopeTagMode -eq "Type") { "policy type" } else { "object group" }
if($selectionIds.Count -eq 0) {
$script:UIProvider.ShowMessageBox("Select at least one $unit to update.", "Bulk Scope Tags", "OK", "Warning") | Out-Null
return
}
$settings = $script:bulkScopeTagForm.DataContext
$settings.ScopeTagIds = @($script:_bulkScopeTagSelectedIds)
# Preflight guards
if($settings.ScopeTagIds.Count -eq 0 -and -not $settings.CleanupOrphans) {
$script:UIProvider.ShowMessageBox("Pick at least one scope tag, or enable 'Cleanup orphans' for a pure cleanup pass.", "Bulk Scope Tags", "OK", "Warning") | Out-Null
return
}
# Mass-wipe guard: Replace with no selected tags will strip every tag
# (including Default) from every matched policy. Defending here rather
# than in the public command keeps the cmdlet itself non-prompting.
if($settings.Action -eq "Replace" -and $settings.ScopeTagIds.Count -eq 0) {
$proceed = $script:UIProvider.ShowMessageBox(
"Replace with no scope tags selected will REMOVE every tag (including Default) from every policy that matches the filter.`n`nMost Intune policies require the Default tag and the API will reject empty roleScopeTagIds - you may end up with a large failure count.`n`nContinue?",
"Confirm wipe-all", "YesNo", "Warning")
if($proceed -ne "Yes") { return }
}
# Default-missing soft guard — mirrors the Details-view "Default scope
# tag missing" warning. Triggers for Add (tags selected but Default
# not included) and Replace (tags selected but Default not included).
# Remove is unaffected because we only remove what the user picked.
if($settings.Action -in @("Add","Replace") -and
$settings.ScopeTagIds.Count -gt 0 -and
$settings.ScopeTagIds -notcontains "0") {
$proceed = $script:UIProvider.ShowMessageBox(
"The 'Default' scope tag (Id 0) is not in the selected list.`n`nIntune usually requires Default to be present; PATCH calls may fail for policies that end up without it.`n`nContinue anyway?",
"Default scope tag missing", "YesNo", "Warning")
if($proceed -ne "Yes") { return }
}
$tagNames = @($script:_bulkScopeTagAll |
Where-Object { $script:_bulkScopeTagSelectedIds.Contains($_.Id) } |
ForEach-Object { $_.Name })
$tagList = if($tagNames.Count -gt 0) { $tagNames -join ", " } else { "(none - cleanup only)" }
$cleanup = if($settings.CleanupOrphans) { "yes" } else { "no" }
$filterText = if($settings.Filter) { $settings.Filter } else { "(none)" }
$confirm = $script:UIProvider.ShowMessageBox(@"
About to update scope tags on every policy that matches:
Action : $($settings.Action)
Tags : $tagList
Cleanup orphans : $cleanup
Name filter : $filterText
$unit count : $($selectionIds.Count)
Continue?
"@, "Confirm Bulk Scope Tag update", "YesNo", "Warning")
if($confirm -ne "Yes") { return }
# Disable both buttons for the duration of the run so a stray click
# doesn't restart it (Apply) or yank the form mid-flight (Close).
if($btnApply) { $btnApply.IsEnabled = $false }
if($btnClose) { $btnClose.IsEnabled = $false }
# No pre-call Write-Status: Set-GraphBulkScopeTags now sets its own two-line
# status ("Bulk scope tags — <Action>" + sub-step) as soon as it starts.
$startParams = @{ ScopeTagSettings = $settings }
if($script:bulkScopeTagMode -eq "Type") { $startParams.PolicyType = $selectionIds }
else { $startParams.PolicyGroup = $selectionIds }
try {
$summary = Set-GraphBulkScopeTags @startParams
Write-Status $null
$msg = ("Scanned: {0}`nMatched filter: {1}`nUpdated: {2}`nNo change: {3}`nFailed: {4}`nDuration: {5:hh\:mm\:ss}" -f `
$summary.PoliciesScanned, $summary.PoliciesMatched, $summary.PoliciesUpdated, $summary.PoliciesSkipped, $summary.PoliciesFailed, $summary.Duration)
$script:txtBulkScopeTagStatus.Text = ("Last run: updated {0}, no change {1}, failed {2}" -f $summary.PoliciesUpdated, $summary.PoliciesSkipped, $summary.PoliciesFailed)
$unknownNote = Get-IntuneUnknownSelectorSummary $summary.UnknownSelectors
if($unknownNote) { $msg += "`n`n$unknownNote" }
$script:UIProvider.ShowMessageBox($msg, "Bulk Scope Tags", "OK", "Information") | Out-Null
# Reflect the new tag values in the main DataGrid (Set-GraphBulkScopeTags
# already mirrored Object.<prop> + cleared _ScopeTags on updated rows).
try { $script:dgIntuneManagerObjects.Items.Refresh() }
catch { Write-LogDebug "Main grid refresh failed after bulk scope tag run: $($_.Exception.Message)" }
}
catch {
Write-LogError "Bulk Scope Tags run failed" $_.Exception
$script:UIProvider.ShowMessageBox("Bulk Scope Tags run failed: $($_.Exception.Message)", "Bulk Scope Tags", "OK", "Error") | Out-Null
}
finally {
if($btnApply) { $btnApply.IsEnabled = $true }
if($btnClose) { $btnClose.IsEnabled = $true }
}
})
$script:UIProvider.AddXamlEvent($script:bulkScopeTagForm, "btnBulkScopeTagClose", "add_click", {
$script:bulkScopeTagForm = $null
Show-ModalObject
})
$script:UIProvider.ShowModalForm("Bulk Scope Tags", $script:bulkScopeTagForm, $true)
try {
$scopeTagLoadError = $null
$script:_bulkScopeTagAll = @(Get-BulkScopeTagCatalog -TokenId (Get-DefaultTokenId) -LoadError ([ref]$scopeTagLoadError))
& $script:_syncBulkScopeTagLists
$script:txtBulkScopeTagStatus.Text = "$($script:_bulkScopeTagAll.Count) scope tag(s) loaded"
if($scopeTagLoadError) {
$script:UIProvider.ShowMessageBox("Could not load scope tags from the tenant.`n`n$scopeTagLoadError`n`nThe picker will only show 'Default'.", "Bulk Scope Tags", "OK", "Warning") | Out-Null
}
}
catch {
Write-LogError "Bulk Scope Tags: failed to initialize scope tag picker" $_.Exception
$script:txtBulkScopeTagStatus.Text = "Scope tag loading failed"
}
}
# Rebuild the bulk-scope-tag object DataGrid for the current view mode.
function Update-BulkScopeTagObjectList
{
if(-not $script:dgBulkScopeTagObjects) { return }
$script:bulkScopeTagObjects = @()
if($script:bulkScopeTagMode -eq "Type") {
$sortedTypes = $script:bulkScopeTagEligibleTypes | Sort-Object Title
foreach($pt in $sortedTypes) {
$script:bulkScopeTagObjects += New-Object PSObject -Property @{
Title = $pt.Title
Selected = $true
ObjectGroup = $null
ObjectType = $pt
}
}
}
else {
$sortedGroups = $script:bulkScopeTagEligibleGroups | Sort-Object Title
foreach($grp in $sortedGroups) {
$script:bulkScopeTagObjects += New-Object PSObject -Property @{
Title = $grp.Title
Selected = $true
ObjectGroup = $grp
ObjectType = $null
}
}
}
$script:dgBulkScopeTagObjects.ItemsSource = $script:bulkScopeTagObjects
}
function Get-BulkScopeTagSelectedObjectIds
{
if($script:bulkScopeTagMode -eq "Type") {
return @($script:bulkScopeTagObjects |
Where-Object { $_.Selected -eq $true -and $_.ObjectType -and $_.ObjectType.Id } |
ForEach-Object { $_.ObjectType.Id })
}
return @($script:bulkScopeTagObjects |
Where-Object { $_.Selected -eq $true -and $_.ObjectGroup -and $_.ObjectGroup.Id } |
ForEach-Object { $_.ObjectGroup.Id })
}
# ─── Bulk Assignments ─────────────────────────────────────────────────────────
# Thin UI wrapper around Set-GraphBulkAssignments. Mirrors the Bulk Scope Tag
# form's structure: Action radio, name filter, eligible-type selector
# (Group/API), plus an assignments list with Add buttons. Phase 1 supports
# group / exclusion-group / all-devices / all-users targets with optional
# assignment filters — app intent + per-platform settings come in later phases.
@@ -0,0 +1,861 @@
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
}
})
}
}
@@ -0,0 +1,296 @@
# Per-view Copy dialog (Copy-IntuneManagerPolicy).
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Copy-IntuneManagerPolicy
{
if(-not $script:dgIntuneManagerObjects.SelectedItem)
{
$script:UIProvider.ShowMessageBox("No object selected`n`nSelect the $($script:IntuneManagerSelectedObject.Title) item you want to copy", "Error", "OK", "Error")
return
}
$script:copyForm = Initialize-Window ($script:AppUIRootFolder + "\Xaml\CopyDialog.xaml")
if(-not $script:copyForm) { return }
$newName = "$($script:dgIntuneManagerObjects.SelectedItem.Name) - Copy"
if($script:dgIntuneManagerObjects.SelectedItem.PolicyType.CopyDefaultName)
{
$newName = $script:dgIntuneManagerObjects.SelectedItem.PolicyType.CopyDefaultName
$script:dgIntuneManagerObjects.SelectedItem.PSObject.Properties | ForEach-Object { $newName = $newName -replace "%$($_.Name)%", $script:dgIntuneManagerObjects.SelectedItem."$($_.Name)" }
}
$script:UIProvider.SetXamlProperty($script:copyForm, "txtObjectName", "Text", $newName)
if($script:dgIntuneManagerObjects.SelectedItem.HasDescription -ne $false)
{
$script:UIProvider.SetXamlProperty($script:copyForm, "txtObjectDescription", "Text", $script:dgIntuneManagerObjects.SelectedItem.Description)
}
else
{
$script:UIProvider.SetXamlProperty($script:copyForm, "txtObjectDescription", "IsEnabled", $false)
}
$tokenList = @()
$tokenList += Get-TokenInfo | ForEach-Object {
$name = $_.TenantName
if($_.IsDefault) {
$name = $name + " (Current)"
}
$_ | Add-Member -NotePropertyName "TenantNameEx" -NotePropertyValue $name | Out-Null
$_
}
$script:UIProvider.SetXamlProperty($script:copyForm, "cbDestinationTenant", "ItemsSource", $tokenList)
$script:UIProvider.SetXamlProperty($script:copyForm, "cbDestinationTenant", "SelectedItem", ($tokenList | Where-Object IsDefault -eq $true))
# ---- Scope tags (dual-list) ----------------------------------------------
# Same pattern as the Details view, with two cross-cutting concerns:
# (1) destination tenant determines which tag list to show — scope tag IDs
# are per-tenant, so when the user picks a different tenant the list
# reloads from THAT tenant's dependency cache.
# (2) Assigned-list seed crosses tenants by NAME (source tag names that
# exist in the destination get pre-checked; the rest drop with a log).
$script:_copyScopeTagPropName = $null
$sourceItem = $script:dgIntuneManagerObjects.SelectedItem
if($sourceItem.PolicyType.ScopeTagProperty) {
$script:_copyScopeTagPropName = [string]$sourceItem.PolicyType.ScopeTagProperty
}
if(-not $script:_copyScopeTagPropName) {
$script:UIProvider.SetXamlProperty($script:copyForm, "pnlCopyScopeTagsLabel", "Visibility", "Collapsed")
$script:UIProvider.SetXamlProperty($script:copyForm, "grdCopyScopeTags", "Visibility", "Collapsed")
}
else {
# Capture the source tags by NAME so we can re-seed the Assigned list
# against the destination tenant whenever the tenant combo changes.
$script:_copySourceScopeTagNames = @()
$sourceTagIds = @()
if($sourceItem.Object -and $sourceItem.Object.PSObject.Properties[$script:_copyScopeTagPropName]) {
$sourceTagIds = @($sourceItem.Object.$script:_copyScopeTagPropName | ForEach-Object { [string]$_ })
}
try {
$srcDeps = Get-GraphDependencySourceObjects $sourceItem -DefaultPoliciesOnly
if($srcDeps -and $srcDeps.ContainsKey("ScopeTags")) {
foreach($id in $sourceTagIds) {
$tag = @($srcDeps["ScopeTags"]) | Where-Object { [string]$_.Id -eq $id } | Select-Object -First 1
if($tag) { $script:_copySourceScopeTagNames += [string]$tag.Name }
}
}
}
catch { Write-LogDebug "Source scope-tag name capture failed: $($_.Exception.Message)" }
$script:colCopyAvailableScopeTags = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
$script:colCopyAssignedScopeTags = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
$script:UIProvider.SetXamlProperty($script:copyForm, "lstCopyAvailableScopeTags", "ItemsSource", $script:colCopyAvailableScopeTags)
$script:UIProvider.SetXamlProperty($script:copyForm, "lstCopyAssignedScopeTags", "ItemsSource", $script:colCopyAssignedScopeTags)
# Single source of truth (same pattern as Details view): full list +
# assigned-ids set; ObservableCollections derived from both. Mutual
# exclusion is structural — no chance of a tag drifting between lists.
$script:_copyAllScopeTags = @()
$script:_copyAssignedIds = [System.Collections.Generic.HashSet[string]]::new()
$script:_syncCopyScopeTagLists = {
$script:colCopyAvailableScopeTags.Clear()
$script:colCopyAssignedScopeTags.Clear()
foreach($tag in $script:_copyAllScopeTags) {
if($script:_copyAssignedIds.Contains($tag.Id)) {
[void]$script:colCopyAssignedScopeTags.Add($tag)
} else {
[void]$script:colCopyAvailableScopeTags.Add($tag)
}
}
}
# Reload both lists for a given destination tenant. Cache lookup first;
# live fetch if cold. Re-seeds the assigned-ids set by name lookup so
# cross-tenant copies pre-pick the same-named tags in the destination.
# Dedupes by Id (the cache can hold both the synthetic Default and the
# real Default scope tag).
$script:_reloadCopyScopeTags = {
param([int]$DestTokenId)
$destTags = @()
try {
$destTokenInfo = Get-TokenInfo $DestTokenId
if($destTokenInfo) {
$cacheId = "DependencyObjects_$($destTokenInfo.TenantId)"
$deps = Get-CacheObject $cacheId
if($deps -and $deps.ContainsKey("ScopeTags")) {
$destTags = @($deps["ScopeTags"])
}
}
}
catch { Write-LogDebug "Copy scope tags: cache lookup failed: $($_.Exception.Message)" }
if($destTags.Count -eq 0) {
# Cold cache — fetch live for this tenant. ScopeTags is small; cheap.
try {
$destTags = @(Get-GraphPolicies -PolicyType "ScopeTags" -TokenId $DestTokenId)
# Mirror what Initialize-TenantDependencyCache does: prepend the
# synthetic Default so it's available for assignment.
$destTags = @([PSCustomObject]@{ ID = 0; Name = "Default" }) + $destTags
}
catch { Write-LogError "Failed to load scope tags for destination tenant" $_.Exception }
}
# Project + dedupe by Id.
$dedup = @()
$seenIds = [System.Collections.Generic.HashSet[string]]::new()
foreach($tag in $destTags) {
$idStr = [string]$tag.Id
if(-not $seenIds.Add($idStr)) { continue }
$dedup += [PSCustomObject]@{ Id = $idStr; Name = [string]$tag.Name }
}
$script:_copyAllScopeTags = @($dedup | Sort-Object Name)
# Re-seed assigned-ids from the captured source tag NAMES (cross-tenant
# safe). Names that don't exist in the destination simply don't get
# selected — the user can pick something else.
$script:_copyAssignedIds.Clear()
$seedNames = [System.Collections.Generic.HashSet[string]]::new()
foreach($n in $script:_copySourceScopeTagNames) { [void]$seedNames.Add($n) }
foreach($tag in $script:_copyAllScopeTags) {
if($seedNames.Contains($tag.Name)) { [void]$script:_copyAssignedIds.Add($tag.Id) }
}
& $script:_syncCopyScopeTagLists
}
# Initial load against the default-selected destination tenant.
$initialDest = $script:UIProvider.GetXamlProperty($script:copyForm, "cbDestinationTenant", "SelectedItem")
if($initialDest) { & $script:_reloadCopyScopeTags $initialDest.Id }
$script:UIProvider.AddXamlEvent($script:copyForm, "cbDestinationTenant", "Add_SelectionChanged", {
$newDest = $script:UIProvider.GetXamlProperty($script:copyForm, "cbDestinationTenant", "SelectedItem")
if($newDest) { & $script:_reloadCopyScopeTags $newDest.Id }
})
# Move = mutate the canonical assigned-ids set, then re-derive both lists.
$script:_moveCopyScopeTags = {
param([string]$Action, [string[]]$Ids)
if($Ids.Count -eq 0) { return }
if($Action -eq "assign") {
foreach($id in $Ids) { [void]$script:_copyAssignedIds.Add([string]$id) }
} else {
foreach($id in $Ids) { [void]$script:_copyAssignedIds.Remove([string]$id) }
}
& $script:_syncCopyScopeTagLists
}
$script:UIProvider.AddXamlEvent($script:copyForm, "btnCopyScopeTagAssign", "Add_Click", {
try {
$lb = $script:copyForm.FindName("lstCopyAvailableScopeTags")
if(-not $lb -or $lb.SelectedItems.Count -eq 0) { return }
$ids = @($lb.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_moveCopyScopeTags "assign" $ids
}
catch { Write-LogError "Copy scope-tag assign handler failed" $_.Exception }
})
$script:UIProvider.AddXamlEvent($script:copyForm, "btnCopyScopeTagUnassign", "Add_Click", {
try {
$lb = $script:copyForm.FindName("lstCopyAssignedScopeTags")
if(-not $lb -or $lb.SelectedItems.Count -eq 0) { return }
$ids = @($lb.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_moveCopyScopeTags "unassign" $ids
}
catch { Write-LogError "Copy scope-tag unassign handler failed" $_.Exception }
})
$script:UIProvider.AddXamlEvent($script:copyForm, "lstCopyAvailableScopeTags", "Add_MouseDoubleClick", {
try {
$lb = $script:copyForm.FindName("lstCopyAvailableScopeTags")
if(-not $lb -or $lb.SelectedItems.Count -eq 0) { return }
$ids = @($lb.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_moveCopyScopeTags "assign" $ids
}
catch { Write-LogError "Copy scope-tag double-click (available) failed" $_.Exception }
})
$script:UIProvider.AddXamlEvent($script:copyForm, "lstCopyAssignedScopeTags", "Add_MouseDoubleClick", {
try {
$lb = $script:copyForm.FindName("lstCopyAssignedScopeTags")
if(-not $lb -or $lb.SelectedItems.Count -eq 0) { return }
$ids = @($lb.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_moveCopyScopeTags "unassign" $ids
}
catch { Write-LogError "Copy scope-tag double-click (assigned) failed" $_.Exception }
})
}
$script:copyForm.Add_ContentRendered({
$txtName = $script:copyForm.FindName("txtObjectName")
if($txtName)
{
$txtName.SelectAll()
}
})
$script:UIProvider.AddXamlEvent($script:copyForm, "btnOk", "Add_Click", {
$script:copyForm.DialogResult = $true
})
$script:copyForm.Owner = $script:window
$script:copyForm.Icon = $script:Window.Icon
$ret = $script:copyForm.ShowDialog()
if($ret)
{
$txtDescrption = $null
$newName = $script:UIProvider.GetXamlProperty($script:copyForm, "txtObjectName", "Text")
if(-not $newName)
{
Write-Log "New name cannot be empty. Copy object skipped" 2
Write-Status ""
return
}
if(($script:UIProvider.GetXamlProperty($script:copyForm, "txtObjectDescription", "IsEnabled")) -eq $true)
{
$txtDescrption = $script:UIProvider.GetXamlProperty($script:copyForm, "txtObjectDescription", "Text")
}
# Export profile
Write-Status "Copy $($script:dgIntuneManagerObjects.SelectedItem.Name)"
#$tokenId = $script:dgIntuneManagerObjects.SelectedItem.TokenId
$selectedTenant = $script:UIProvider.GetXamlProperty($script:copyForm, "cbDestinationTenant", "SelectedItem")
$tokenId = $selectedTenant.Id
$copyArgs = @{
InputObject = $script:dgIntuneManagerObjects.SelectedItem
Name = $newName
Description = $txtDescrption
TokenId = $tokenId
}
# Forward the user's scope-tag picks (when the section was shown). Pass
# even an empty array so the new copy explicitly reflects user intent —
# CopyObject will override the cloned JSON only when this is non-null.
# Read from the canonical $script:_copyAssignedIds set (single source of
# truth) rather than the UI-derived ObservableCollection.
if($script:_copyScopeTagPropName -and $null -ne $script:_copyAssignedIds) {
$copyArgs['ScopeTagIds'] = @($script:_copyAssignedIds)
}
$newPolicies = Copy-GraphPolicy @copyArgs
if($newPolicies -and (Get-SettingValue "RefreshObjectsAfterCopy") -eq $true) {
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject
}
#if($script:dgIntuneManagerObjects.SelectedItem.CopyObject($newName, $txtDescrption, 0)) {
# if((Get-SettingValue "RefreshObjectsAfterCopy") -eq $true)
# {
# Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject
# }
#}
Write-Status ""
}
$script:dgIntuneManagerObjects.Focus()
}
#region Events
@@ -0,0 +1,47 @@
# Per-view Delete confirmation (Remove-GraphObjectsUI).
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Remove-GraphObjectsUI
{
$policiesToRemove = @()
if(($script:dgIntuneManagerObjects.ItemsSource | Where-Object IsSelected -eq $true).Count -gt 0)
{
$policiesToRemove += $script:dgIntuneManagerObjects.ItemsSource | Where-Object IsSelected -eq $true
}
elseif($script:dgIntuneManagerObjects.SelectedItem)
{
$policiesToRemove += $script:dgIntuneManagerObjects.SelectedItem
}
if($policiesToRemove.Count -eq 0)
{
$script:UIProvider.ShowMessageBox("No object selected`n`nSelect items you want to delete", "Error", "OK", "Error")
return
}
if(($script:UIProvider.ShowMessageBox("Are you sure you want to delete $($policiesToRemove.Count) object(s)?`n`nEnvironment: $($script:OrganizationName)", "Delete Objects?", "YesNo", "Warning")) -ne "Yes")
{
return
}
$deletedPolicies = $policiesToRemove | Remove-GraphPolicy
if($deletedPolicies) {
$script:dgIntuneManagerObjects.ItemsSource
$deletedPolicies | ForEach-Object {
try {
$index = [Array]::IndexOf($script:dgIntuneManagerObjects.ItemsSource.Id, $_.Id)
if($index -gt -1) {
($script:dgIntuneManagerObjects.ItemsSource).RemoveAt($index)
}
}
catch { }
}
}
Write-Status ""
}
@@ -0,0 +1,670 @@
# Details / object-view dialog (Show-IntuneManagerDetailedView + helpers).
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Get-DetailsSettingsSignature
{
param($Form)
if(-not $Form) { return "" }
$scopeTagIds = @()
if($script:_scopeTagPropName -and $null -ne $script:_detailsAssignedIds) {
$scopeTagIds = @($script:_detailsAssignedIds | Sort-Object)
}
$descriptionEnabled = $false
try { $descriptionEnabled = [bool]($script:UIProvider.GetXamlProperty($Form, "txtObjectDescription", "IsEnabled")) } catch {}
$signature = [PSCustomObject]@{
Name = [string]($script:UIProvider.GetXamlProperty($Form, "txtObjectName", "Text"))
DescriptionEnabled = $descriptionEnabled
Description = if($descriptionEnabled) { [string]($script:UIProvider.GetXamlProperty($Form, "txtObjectDescription", "Text")) } else { $null }
ScopeTagProperty = [string]$script:_scopeTagPropName
ScopeTagIds = $scopeTagIds
}
return ($signature | ConvertTo-Json -Depth 10 -Compress)
}
function Update-DetailsColumnEditorBindings
{
param($Form)
if(-not $Form) { return }
foreach($controlName in @("txtObjectColumnsProperty", "txtObjectColumnsHeader", "chkObjectColumnOverride")) {
try {
$ctl = $Form.FindName($controlName)
if(-not $ctl) { continue }
$expression = $ctl.GetBindingExpression([System.Windows.Controls.TextBox]::TextProperty)
if(-not $expression -and $controlName -eq "chkObjectColumnOverride") {
$expression = $ctl.GetBindingExpression([System.Windows.Controls.Primitives.ToggleButton]::IsCheckedProperty)
}
if($expression) { $expression.UpdateSource() }
}
catch { }
}
}
function Get-DetailsColumnsSignature
{
param($Form)
if(-not $Form) { return "" }
Update-DetailsColumnEditorBindings -Form $Form
$columns = @()
foreach($col in @($script:colObjectProperties)) {
if(-not $col) { continue }
$columns += [PSCustomObject]@{
Property = [string]$col.Property
Header = [string]$col.Header
}
}
$signature = [PSCustomObject]@{
OverrideDefaultColumns = [bool]($script:UIProvider.GetXamlProperty($Form, "chkObjectColumnOverride", "IsChecked"))
Columns = $columns
}
return ($signature | ConvertTo-Json -Depth 20 -Compress)
}
function Test-DetailsViewHasUnsavedChanges
{
param($Form)
if(-not $Form) { return $false }
$settingsDirty = $false
$columnsDirty = $false
try {
if($script:_detailsInitialSettingsSignature) {
$settingsDirty = ((Get-DetailsSettingsSignature -Form $Form) -ne $script:_detailsInitialSettingsSignature)
}
}
catch { Write-LogDebug "Details settings dirty check failed: $($_.Exception.Message)" }
try {
if($script:_detailsInitialColumnsSignature) {
$columnsDirty = ((Get-DetailsColumnsSignature -Form $Form) -ne $script:_detailsInitialColumnsSignature)
}
}
catch { Write-LogDebug "Details columns dirty check failed: $($_.Exception.Message)" }
return ($settingsDirty -or $columnsDirty)
}
function Confirm-DetailsViewClose
{
if(-not (Test-DetailsViewHasUnsavedChanges -Form $script:detailsForm)) { return $true }
$result = $script:UIProvider.ShowMessageBox(
"You have unsaved changes in Settings or Columns.`n`nClose without saving?",
"Unsaved changes", "YesNo", "Warning")
return ($result -eq "Yes")
}
function Show-IntuneManagerDetailedView
{
param(
$FormTitle = "",
[switch]$NoLoadFull)
$selectedItem = $script:dgIntuneManagerObjects.SelectedItem
if(-not $selectedItem -and $script:dgIntuneManagerObjects.ItemsSource) {
$selectedItem = @($script:dgIntuneManagerObjects.ItemsSource |
Where-Object { $_.PSObject.Properties['IsSelected'] -and $_.IsSelected -eq $true } |
Select-Object -First 1)
if($selectedItem.Count -gt 0) { $selectedItem = $selectedItem[0] }
}
if(-not $selectedItem) { return }
if(-not $selectedItem.JsonObject) { return }
$script:detailsForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\ObjectDetails.xaml"))
if(-not $script:detailsForm) { return }
$script:detailsForm.Tag = $selectedItem
$script:detailsForm | Add-Member -MemberType ScriptMethod -Name ConfirmClose -Value { Confirm-DetailsViewClose } -Force
if(-not $FormTitle) { $FormTitle = $selectedItem.PolicyName }
$objName = $selectedItem.Name
if($objName)
{
$FormTitle = "$FormTitle - $objName"
}
if($selectedItem.PolicyType.AddUIDetailsExtension) {
$selectedItem.PolicyType.AddUIDetailsExtension($script:detailsForm)
}
$script:UIProvider.SetXamlProperty($script:detailsForm, "txtValue", "Text", $selectedItem.JsonString)
if($selectedItem.PolicyType.AllowFullDetails -eq $false)
{
$script:UIProvider.SetXamlProperty($script:detailsForm, "btnFull", "Visibility", "Collapsed")
}
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnCopy", "Add_Click", {
$tmp = $script:detailsForm.FindName("txtValue")
if($tmp.Text) { $tmp.Text | Set-Clipboard }
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnFull", "Add_Click", {
$selectedItem = $script:detailsForm.Tag
if(-not $selectedItem) { return }
Write-Status "Get full object $($selectedItem.Name)"
[void]$selectedItem.Get()
if($selectedItem.IsFullObject)
{
$script:UIProvider.SetXamlProperty($script:detailsForm, "txtValue", "Text", $selectedItem.JsonString)
}
Write-Status ""
})
#Settings tab
$script:UIProvider.SetXamlProperty($script:detailsForm, "txtObjectName", "Text", $selectedItem.Name)
$script:UIProvider.SetXamlProperty($script:detailsForm, "txtObjectDescription", "Text", $selectedItem.Description)
# Snapshots for the save-confirmation dialog: it names the object and says
# exactly what changed, computed against these open-time values. The
# scope-tag pair resets here because the picker block below only runs for
# types WITH a scope-tag property - without the reset a previous dialog's
# sets would leak into this object's change summary.
$script:_detailsOriginalName = [string]$selectedItem.Name
$script:_detailsOriginalDesc = [string]$selectedItem.Description
$script:_detailsOriginalAssignedIds = $null
# ---- Scope tags (dual-list) ----------------------------------------------
# Type-level capability: _ScopeTagProperty defaults to "roleScopeTagIds" on every
# IntunePolicyType, but a few types clear it. Whether the property currently exists
# on the JSON is irrelevant — a user can assign tags to a policy that has none yet.
$script:_scopeTagPropName = $null
if($selectedItem.PolicyType.ScopeTagProperty) {
$script:_scopeTagPropName = [string]$selectedItem.PolicyType.ScopeTagProperty
}
if(-not $script:_scopeTagPropName) {
$script:UIProvider.SetXamlProperty($script:detailsForm, "pnlScopeTagsLabel", "Visibility", "Collapsed")
$script:UIProvider.SetXamlProperty($script:detailsForm, "grdScopeTags", "Visibility", "Collapsed")
}
else {
# Source the full tag list from the warm dependency cache
# (Initialize-TenantDependencyCache preloads it on auth). The cache list
# includes the synthetic Default (Id=0). Falls back to a live call if the
# cache is empty (e.g. a session that started before the preload landed).
$allScopeTags = @()
try {
$depObjects = Get-GraphDependencySourceObjects $selectedItem -DefaultPoliciesOnly
if($depObjects -and $depObjects.ContainsKey("ScopeTags")) {
$allScopeTags = @($depObjects["ScopeTags"])
}
}
catch { Write-LogDebug "Scope tag dependency cache lookup failed: $($_.Exception.Message)" }
if($allScopeTags.Count -eq 0) {
$allScopeTags = @(Get-GraphPolicies -PolicyType "ScopeTags" -TokenId $selectedItem._TokenId)
}
# Resolve currently-assigned IDs from the JSON. Stringify so the comparison
# works regardless of whether the cache entry stores Id as int (synthetic
# Default) or string (real tags from Graph). Safe when the property is
# absent — PowerShell yields $null which the empty-array cast swallows.
$assignedIdSet = [System.Collections.Generic.HashSet[string]]::new()
$currentIds = $null
if($selectedItem.Object -and
$selectedItem.Object.PSObject.Properties[$script:_scopeTagPropName]) {
$currentIds = $selectedItem.Object.$script:_scopeTagPropName
}
foreach($id in @($currentIds)) {
if($null -ne $id) { [void]$assignedIdSet.Add([string]$id) }
}
# Project to plain PSCustomObject {Id, Name} so the ListBox binding works
# uniformly. The cache mixes two shapes: the synthetic Default is a real
# PSCustomObject (renders fine), but real tags are IntunePolicyBase class
# instances with Name added as a ScriptProperty via Add-Member — WPF's
# DisplayMemberPath reflection on class instances doesn't reliably see
# ScriptProperties, so those rows render blank. Projecting normalizes both.
#
# Dedup by Id while projecting. The dependency cache can contain BOTH the
# synthetic Default and the real Default scope tag from Graph (both have
# Id "0"); without dedup, "Default" rendered twice and a single Assigned
# move could leave a duplicate stuck in the wrong list.
$script:_detailsAllScopeTags = @()
$seenIds = [System.Collections.Generic.HashSet[string]]::new()
foreach($tag in $allScopeTags) {
$idStr = [string]$tag.Id
if(-not $seenIds.Add($idStr)) { continue }
$script:_detailsAllScopeTags += [PSCustomObject]@{ Id = $idStr; Name = [string]$tag.Name }
}
$script:_detailsAllScopeTags = @($script:_detailsAllScopeTags | Sort-Object Name)
# Single source of truth for which tags are currently assigned. Both
# ObservableCollections are derived from this set + the full list, so
# mutual exclusion is structural — Available is always "everything that
# isn't in the assigned set", no chance of duplicate or drift bugs.
$script:_detailsAssignedIds = [System.Collections.Generic.HashSet[string]]::new()
foreach($id in $assignedIdSet) { [void]$script:_detailsAssignedIds.Add($id) }
$script:_detailsOriginalAssignedIds = [System.Collections.Generic.HashSet[string]]::new($script:_detailsAssignedIds)
$script:colAvailableScopeTags = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
$script:colAssignedScopeTags = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
$script:_syncDetailsScopeTagLists = {
$script:colAvailableScopeTags.Clear()
$script:colAssignedScopeTags.Clear()
foreach($tag in $script:_detailsAllScopeTags) {
if($script:_detailsAssignedIds.Contains($tag.Id)) {
[void]$script:colAssignedScopeTags.Add($tag)
} else {
[void]$script:colAvailableScopeTags.Add($tag)
}
}
}
& $script:_syncDetailsScopeTagLists
$script:UIProvider.SetXamlProperty($script:detailsForm, "lstAvailableScopeTags", "ItemsSource", $script:colAvailableScopeTags)
$script:UIProvider.SetXamlProperty($script:detailsForm, "lstAssignedScopeTags", "ItemsSource", $script:colAssignedScopeTags)
# Click handlers reference only $script:-scoped state so they keep access
# to module-private helpers (Get-XamlProperty etc.). Move = mutate the
# canonical assigned-ids set, then re-derive both lists.
$script:_moveScopeTags = {
param([string]$Action, [string[]]$Ids)
if($Ids.Count -eq 0) { return }
if($Action -eq "assign") {
foreach($id in $Ids) { [void]$script:_detailsAssignedIds.Add([string]$id) }
} else {
foreach($id in $Ids) { [void]$script:_detailsAssignedIds.Remove([string]$id) }
}
& $script:_syncDetailsScopeTagLists
}
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnScopeTagAssign", "Add_Click", {
try {
$lb = $script:detailsForm.FindName("lstAvailableScopeTags")
if(-not $lb -or $lb.SelectedItems.Count -eq 0) {
Write-Status "Select one or more scope tag(s) in the Available list first"
return
}
$ids = @($lb.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_moveScopeTags "assign" $ids
}
catch { Write-LogError "Scope-tag assign handler failed" $_.Exception }
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnScopeTagUnassign", "Add_Click", {
try {
$lb = $script:detailsForm.FindName("lstAssignedScopeTags")
if(-not $lb -or $lb.SelectedItems.Count -eq 0) {
Write-Status "Select one or more scope tag(s) in the Assigned list first"
return
}
$ids = @($lb.SelectedItems | ForEach-Object { [string]$_.Id })
& $script:_moveScopeTags "unassign" $ids
}
catch { Write-LogError "Scope-tag unassign handler failed" $_.Exception }
})
# Double-click toggles. Lets the user assign/unassign without aiming for
# the arrow buttons, which is the more common interaction pattern.
$script:UIProvider.AddXamlEvent($script:detailsForm, "lstAvailableScopeTags", "Add_MouseDoubleClick", {
try {
$ids = @(@($script:UIProvider.GetXamlProperty($script:detailsForm, "lstAvailableScopeTags", "SelectedItems")) | ForEach-Object { [string]$_.Id })
& $script:_moveScopeTags "assign" $ids
}
catch { Write-LogError "Scope-tag double-click (available) failed" $_.Exception }
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "lstAssignedScopeTags", "Add_MouseDoubleClick", {
try {
$ids = @(@($script:UIProvider.GetXamlProperty($script:detailsForm, "lstAssignedScopeTags", "SelectedItems")) | ForEach-Object { [string]$_.Id })
& $script:_moveScopeTags "unassign" $ids
}
catch { Write-LogError "Scope-tag double-click (assigned) failed" $_.Exception }
})
}
$script:_detailsInitialSettingsSignature = Get-DetailsSettingsSignature -Form $script:detailsForm
# ToDo: Disable description if property does not exist
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnObjectSettingsSave", "Add_Click", {
$selectedItem = $script:detailsForm.Tag
if(-not $selectedItem) { return }
# Name the object and say what changed - computed against the
# open-time snapshots so the dialog is meaningful.
$originalName = if($script:_detailsOriginalName) { $script:_detailsOriginalName } else { [string]$selectedItem.Name }
$newName = [string]($script:UIProvider.GetXamlProperty($script:detailsForm, "txtObjectName", "Text"))
$newDesc = [string]($script:UIProvider.GetXamlProperty($script:detailsForm, "txtObjectDescription", "Text"))
$changes = @()
if($newName -and $newName -cne $originalName) { $changes += "New name: '$newName'" }
if($newDesc -cne [string]$script:_detailsOriginalDesc) { $changes += "Description updated" }
if($null -ne $script:_detailsAssignedIds -and $null -ne $script:_detailsOriginalAssignedIds)
{
$added = @($script:_detailsAssignedIds | Where-Object { -not $script:_detailsOriginalAssignedIds.Contains($_) }).Count
$removed = @($script:_detailsOriginalAssignedIds | Where-Object { -not $script:_detailsAssignedIds.Contains($_) }).Count
if($added -or $removed) { $changes += "Scope tags: $added added, $removed removed" }
}
$confirmMsg = "Are you sure you want to update '$originalName'?"
if($changes.Count) { $confirmMsg += "`n`n" + ($changes -join "`n") }
if(($script:UIProvider.ShowMessageBox($confirmMsg, "Update object information?", "YesNo", "Warning")) -ne "Yes")
{
return
}
Write-Status "Update object information for $($selectedItem.Name)"
$nameValue = ($script:UIProvider.GetXamlProperty($script:detailsForm, "txtObjectName", "Text"))
if(-not $nameValue)
{
$script:UIProvider.ShowMessageBox("Name property must not be empty!", "Error", "OK", "Error")
return
}
# Save settings here...
$nameProp = (?? $selectedItem.PolicyType.NameProperty "displayName")
$idProp = (?? $selectedItem.PolicyType.IDProperty "id")
$updateHT = @{}
$updateHT.Add($idProp, $selectedItem.ID)
$updateHT.Add($nameProp, $nameValue)
if(($selectedItem.Object."@odata.type"))
{
$updateHT.Add("@odata.type", ($selectedItem.JsonObject."@odata.type"))
}
if(($script:UIProvider.GetXamlProperty($script:detailsForm, "txtObjectDescription", "IsEnabled")) -eq $true)
{
$updateHT.Add("description", ($script:UIProvider.GetXamlProperty($script:detailsForm, "txtObjectDescription", "Text")))
if($null -eq $updateHT["description"] -and $selectedItem.Description)
{
# If description is null, remove it
$updateHT["description"] = ""
}
elseif($null -eq $updateHT["description"])
{
$updateHT.Remove("description")
}
}
# Scope tags: write the assigned-list IDs back under the policy's
# ScopeTagProperty (typically roleScopeTagIds). Read from the canonical
# $script:_detailsAssignedIds set (single source of truth) — the
# ObservableCollection is just a UI derivative.
if($script:_scopeTagPropName -and $null -ne $script:_detailsAssignedIds) {
$assignedIds = @($script:_detailsAssignedIds)
# Soft guard: Intune treats roleScopeTagIds as containing "0" (Default)
# by default. A non-empty list that omits Default is unusual and often
# rejected by Graph; warn before committing rather than letting the
# PATCH 400 silently. Empty list isn't flagged — that reads as a
# deliberate reset.
if($assignedIds.Count -gt 0 -and $assignedIds -notcontains "0") {
$proceed = $script:UIProvider.ShowMessageBox(
"The 'Default' scope tag (Id 0) is not in the assigned list.`n`nIntune usually requires Default to be present and the API call may fail.`n`nContinue anyway?",
"Default scope tag missing", "YesNo", "Warning")
if($proceed -ne "Yes") {
Write-Status ""
return
}
}
$updateHT.Add($script:_scopeTagPropName, $assignedIds)
}
$updateObj = [PSCustomObject]$updateHT
$api = "$($selectedItem.PolicyType.API)/$($selectedItem.ID)"
$json = $updateObj | ConvertTo-Json -Depth 20
$ret = Invoke-MSGraphAPI $api -HttpMethod "PATCH" -Content $json -FullResponseObject -TokenId $selectedItem._TokenId
Write-Status ""
if($ret.Success -eq $false)
{
Write-log "Failed to update object information updated for $($selectedItem.Name). Error: $($ret.StatusCode) - $($ret.StatusDescription)"
$script:UIProvider.ShowMessageBox("Object information could not be verified!`n`nCheck the log file", "Update warning", "OK", "Warning")
}
else {
Write-log "Object information updated for $($selectedItem.Name)"
# Refresh the in-memory object so the parent grid's "ScopeTags" column
# reflects what we just sent. Clearing _ScopeTags forces the lazy
# getter to re-resolve from the dependency cache next access; updating
# Object.<prop> keeps subsequent reads (and a re-open of this dialog)
# in sync without a round-trip. Items.Refresh() then asks WPF to
# re-read every bound cell — without it, the DataGrid keeps showing
# the pre-Save snapshot because PSCustomObject doesn't notify.
if($script:_scopeTagPropName -and $null -ne $script:_detailsAssignedIds) {
$assignedIds = @($script:_detailsAssignedIds)
try {
$selectedItem.Object.$script:_scopeTagPropName = $assignedIds
} catch { Write-LogDebug "Failed to update in-memory ScopeTags array: $($_.Exception.Message)" }
$selectedItem._ScopeTags = $null
$selectedItem._ScopeTagsString = $null
}
# Name / description may also have changed in this Save — refresh once
# at the end so every cell of the affected row re-reads, not just the
# ScopeTags column.
try { $script:dgIntuneManagerObjects.Items.Refresh() }
catch { Write-LogDebug "DataGrid refresh failed: $($_.Exception.Message)" }
$script:_detailsInitialSettingsSignature = Get-DetailsSettingsSignature -Form $script:detailsForm
}
})
#Columns tab
$skipBasicProperties = @("JsonObject", "Object", "JsonString", "TenantId", "TokenId", "IsSelected")
$arrBasicProperties = @()
foreach($prop in ($selectedItem.PSObject.Properties | Where-Object Name -notin $skipBasicProperties))
{
$arrBasicProperties += ([PSCustomObject]@{ Name=$prop.Name;Value=$prop;Source="Basic" })
}
$arrBasicProperties = $arrBasicProperties | Sort-Object -Property Name
$script:UIProvider.SetXamlProperty($script:detailsForm, "lstBasicProperties", "ItemsSource", $arrBasicProperties)
$arrObjectProperties = @()
$skipObjectProperties = @("@odata.editLink")
foreach($prop in ($selectedItem.Object.PSObject.Properties | Where-Object { $_.Name -notin $skipObjectProperties -and $_.Name -notlike "*?@odata.*" }))
{
$arrObjectProperties += ([PSCustomObject]@{ Name=$prop.Name;Value=$prop;Source="Object" })
}
$arrObjectProperties = $arrObjectProperties | Sort-Object -Property Name
$script:UIProvider.SetXamlProperty($script:detailsForm, "lstObjectProperties", "ItemsSource", $arrObjectProperties)
$script:colObjectProperties = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
$script:UIProvider.SetXamlProperty($script:detailsForm, "lstObjectColumns", "ItemsSource", $script:colObjectProperties)
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnObjectColumnsReset", "Add_Click", {
<#
if(([System.Windows.MessageBox]::Show("Are you sure you want to reset columns to default?", "Reset Columns?", "YesNo", "Warning")) -ne "Yes")
{
return
}
$selectedType = Get-SelectedObjectTypeString
Remove-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)"
#>
$script:colObjectProperties.Clear()
Show-ObjectDefaultColumnsSettings -Reset
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "lstObjectColumns", "Add_SelectionChanged", {
$script:UIProvider.SetXamlProperty($script:detailsForm, "grdObjectColumns", "DataContext", ($script:UIProvider.GetXamlProperty($script:detailsForm, "lstObjectColumns", "SelectedItem")))
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnBasicColumnsAdd", "Add_Click", {
$selectedItem = $script:UIProvider.GetXamlProperty($script:detailsForm, "lstBasicProperties", "SelectedItem")
if($selectedItem) {
$script:colObjectProperties.Add(([ObjectColumnInfo]::new($selectedItem.Name, "")))
}
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnObjectColumnsAdd", "Add_Click", {
$selectedItem = $script:UIProvider.GetXamlProperty($script:detailsForm, "lstObjectProperties", "SelectedItem")
if($selectedItem) {
$script:colObjectProperties.Add(([ObjectColumnInfo]::new("Object." + $selectedItem.Name, "")))
}
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnObjectColumnsMoveUp", "Add_Click", {
$selectedIndex = $script:UIProvider.GetXamlProperty($script:detailsForm, "lstObjectColumns", "SelectedIndex")
if($selectedIndex -gt 0)
{
$tmpObj = $script:colObjectProperties[$selectedIndex]
$script:colObjectProperties.RemoveAt($selectedIndex)
$tmpObj = $script:colObjectProperties.Insert(($selectedIndex-1),$tmpObj)
$script:UIProvider.SetXamlProperty($script:detailsForm, "lstObjectColumns", "SelectedIndex", ($selectedIndex-1))
}
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnObjectColumnsMoveDown", "Add_Click", {
$selectedIndex = $script:UIProvider.GetXamlProperty($script:detailsForm, "lstObjectColumns", "SelectedIndex")
if($selectedIndex -ge 0 -and $selectedIndex -lt ($script:colObjectProperties.Count-1)) {
$tmpObj = $script:colObjectProperties[$selectedIndex]
$script:colObjectProperties.RemoveAt($selectedIndex)
$tmpObj = $script:colObjectProperties.Insert(($selectedIndex+1),$tmpObj)
$script:UIProvider.SetXamlProperty($script:detailsForm, "lstObjectColumns", "SelectedIndex", ($selectedIndex+1))
}
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnObjectColumnsDelete", "Add_Click", {
$selectedIndex = $script:UIProvider.GetXamlProperty($script:detailsForm, "lstObjectColumns", "SelectedIndex")
if($selectedIndex -ge 0) {
if(($script:UIProvider.ShowMessageBox("Are you sure you want to remove selected column?", "Remove Columns?", "YesNo", "Warning")) -ne "Yes")
{
return
}
$script:colObjectProperties.RemoveAt($selectedIndex)
}
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnObjectColumnsClear", "Add_Click", {
if(($script:UIProvider.ShowMessageBox("Are you sure you want to clear custom column settings?", "Clear Custom Columns?", "YesNo", "Warning")) -ne "Yes") {
return
}
$script:colObjectProperties.Clear()
})
$script:UIProvider.AddXamlEvent($script:detailsForm, "btnObjectColumnsSave", "Add_Click", {
$selectedType = Get-SelectedObjectTypeString
if(-not $selectedType) { return }
if(($script:UIProvider.ShowMessageBox("Are you sure you want to save custom column settings?", "Save Custom Columns?", "YesNo", "Warning")) -ne "Yes")
{
return
}
if($script:colObjectProperties.Count -gt 0)
{
$arrCols = @()
if(($script:UIProvider.GetXamlProperty($script:detailsForm, "chkObjectColumnOverride", "IsChecked")) -eq $true)
{
$arrCols += "0"
}
foreach($colProp in $script:colObjectProperties)
{
$tmp = $colProp.Property
if($colProp.Header -and $colProp.Header -cne $colProp.Property)
{
$tmp = "$($tmp)=$($colProp.Header)"
}
$arrCols += $tmp
}
$strCols = $arrCols -join ","
Save-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)" $strCols
}
else
{
$strCols = $null
Remove-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)"
}
Show-ObjectDefaultColumnsSettings
$script:_detailsInitialColumnsSignature = Get-DetailsColumnsSignature -Form $script:detailsForm
})
Show-ObjectDefaultColumnsSettings
$script:_detailsInitialColumnsSignature = Get-DetailsColumnsSignature -Form $script:detailsForm
# Show dialog
$script:UIProvider.ShowModalForm($FormTitle, $detailsForm)
}
function Show-ObjectDefaultColumnsSettings
{
param([switch]$Reset)
if($Reset -ne $true) {
$selectedType = Get-SelectedObjectTypeString
if(-not $selectedType) { return }
$strColSettings = Get-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)"
}
else {
$strColSettings = ""
}
$script:colObjectProperties.Clear()
$defaultColumns = $script:IntuneManagerSelectedObject.ViewProperties
if($strColSettings)
{
$arrColSettings += $strColSettings -split ",|;"
$script:UIProvider.SetXamlProperty($script:detailsForm, "chkObjectColumnOverride", "IsChecked", ($arrColSettings.Count -gt 0 -and $arrColSettings[0] -eq "0"))
$script:UIProvider.SetXamlProperty($script:detailsForm, "lblObjectColumnsConfig", "Text", $strColSettings)
$start = 0
if($arrColSettings.Count -gt 0 -and ($arrColSettings[0] -eq "0" -or $arrColSettings[0] -eq "1"))
{
$start++
}
$colArr = @()
for($i = $start;$i -lt $arrColSettings.Count;$i++)
{
$colProp,$colHeader= $arrColSettings[$i].Split("=")
if(-not $colHeader)
{
$colHeader = $colProp
}
$script:colObjectProperties.Add([ObjectColumnInfo]::new($colProp,$colHeader))
$colArr += $colProp
}
if(($arrColSettings.Count -eq 0 -or $arrColSettings[0] -ne "0"))
{
$tmpArr = $defaultColumns
$tmpArr += $colArr
$colArr = $tmpArr
}
$script:UIProvider.SetXamlProperty($script:detailsForm, "lblObjectColumnsConfig", "Text", ("$(($colArr-join ','))"))
}
else
{
$script:UIProvider.SetXamlProperty($script:detailsForm, "lblObjectColumnsConfig", "Text", "$(($defaultColumns -join ',')) (Default)")
}
}
# Match-resolution helpers (Get-IntuneImportPolicyReferenceTokens,
# Normalize-IntuneImportPolicyName, New-IntuneImportMatchResult,
# Resolve-IntuneImportUpdateTarget) moved to Internal/IntuneManager.ps1
# so the Avalonia tree can share them.
@@ -0,0 +1,73 @@
# Per-view Export dialog (single-policy/single-type export).
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Show-IntuneManagerExportForm
{
$script:exportForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\ExportForm.xaml"))
if(-not $script:exportForm) { return }
$script:UIProvider.SetXamlProperty($script:exportForm, "txtExportPath", "Text", (?? (Get-SettingStoreValue "" "LastUsedRoot") (Get-SettingValue "RootFolder")))
$exportSettings = [IntuneManagerExportSettings]::new()
$policyTypes = Get-IntuneManagerSelectedPolicyTypes
$policyTypes | Add-IntuneManagerExportProperties -ExportSettings $exportSettings
$policyTypes | Add-IntuneManagerExportUIExtensions -Form $script:exportForm
$script:exportForm.DataContext = $exportSettings
$script:UIProvider.SetXamlProperty($script:exportForm, "btnExportSelected", "IsEnabled", ($null -ne $script:dgIntuneManagerObjects.SelectedItem))
if(($script:dgIntuneManagerObjects.ItemsSource | Where-Object IsSelected -eq $true).Count -gt 0)
{
$script:UIProvider.SetXamlProperty($script:exportForm, "lblSelectedObject", "Content", "$(($script:dgIntuneManagerObjects.ItemsSource | Where-Object IsSelected -eq $true).Count) selected object(s)")
}
elseif($script:dgIntuneManagerObjects.SelectedItem)
{
$script:UIProvider.SetXamlProperty($script:exportForm, "lblSelectedObject", "Content", "Selected object: $($script:dgIntuneManagerObjects.SelectedItem.Name)")
}
$script:UIProvider.AddXamlEvent($script:exportForm, "btnCancel", "add_click", {
$script:exportForm = $null
Show-ModalObject
})
$script:UIProvider.AddXamlEvent($script:exportForm, "btnExportSelected", "add_click", {
$selectedItems = @()
if(($script:dgIntuneManagerObjects.ItemsSource | Where-Object IsSelected -eq $true).Count -gt 0) {
$selectedItems += $script:dgIntuneManagerObjects.ItemsSource | Where-Object IsSelected -eq $true
}
else {
$selectedItems += $script:dgIntuneManagerObjects.SelectedItem
}
$selectedItems | Export-GraphPolicy -ExportSettings $script:exportForm.DataContext
$script:exportForm.DataContext.Save()
$script:exportForm = $null
Show-ModalObject
})
$script:UIProvider.AddXamlEvent($script:exportForm, "btnExportAll", "add_click", {
$script:dgIntuneManagerObjects.ItemsSource | Export-GraphPolicy -ExportSettings $script:exportForm.DataContext
$script:exportForm = $null
Show-ModalObject
})
$script:UIProvider.AddXamlEvent($script:exportForm, "browseExportPath", "add_click", {
$folder = Get-Folder ($script:UIProvider.GetXamlProperty($script:exportForm, "txtExportPath", "Text")) "Select root folder for export"
if($folder)
{
$script:exportForm.DataContext.ExportFolder = $folder
$tmp = $script:exportForm.DataContext
$script:exportForm.DataContext = $null
$script:exportForm.DataContext = $tmp
}
})
$script:UIProvider.ShowModalForm("Export $($script:IntuneManagerSelectedObject.Title) objects", $script:exportForm, $true)
}
@@ -0,0 +1,54 @@
# Add-IntuneManagerExportUIExtensions / Add-IntuneManagerImportUIExtensions iterators.
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Add-IntuneManagerExportUIExtensions
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[IntunePolicyTypeBase[]]
$InputObject,
[Parameter(Mandatory = $true)]
[Object]
$Form
)
Begin { Write-Log "Add Export UI Extensions - Start" }
Process {
foreach($policyType in $InputObject) {
if($policyType.AddUIExportExtensions) {
$policyType.AddUIExportExtensions($Form)
}
}
}
End { Write-Log "Add Export UI Extensions - Done" }
}
function Add-IntuneManagerImportUIExtensions
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[IntunePolicyTypeBase[]]
$InputObject,
[Parameter(Mandatory = $true)]
[Object]
$Form
)
Begin { Write-Log "Add Import UI Extensions - Start" }
Process {
foreach($policyType in $InputObject) {
if($policyType.AddUIImportExtensions) {
$policyType.AddUIImportExtensions($Form)
}
}
}
End { Write-Log "Add Import UI Extensions - Done" }
}
@@ -0,0 +1,338 @@
# Per-view Import dialog (single-policy/single-folder import). Includes the kept-on-purpose `<# ToDo #>` Get-PoliciesFromFile stub block.
# Split out of IntuneManagerUIWPF.ps1 on 2026-06-03 to match the Avalonia tree's
# per-feature layout (architecture rule R9 — keep the WPF shell from growing further).
function Update-IntuneImportMatchInfo
{
if(-not $script:DGObjectsToImport -or -not $script:DGObjectsToImport.ItemsSource) { return }
$importType = $script:importForm.DataContext.ImportType
$sameTenant = $script:importForm.DataContext.SameTenant
$existingPolicies = @($script:dgIntuneManagerObjects.ItemsSource)
foreach($item in @($script:DGObjectsToImport.ItemsSource)) {
if(-not $item -or -not $item.Object) { continue }
$match = Resolve-IntuneImportUpdateTarget -ImportPolicy $item.Object -ExistingPolicies $existingPolicies -SameTenant $sameTenant -ImportType $importType
$item | Add-Member -MemberType NoteProperty -Name "ImportAction" -Value $match.Action -Force
$item | Add-Member -MemberType NoteProperty -Name "ImportMatch" -Value $match.Message -Force
$item | Add-Member -MemberType NoteProperty -Name "ImportMatchStrategy" -Value $match.Strategy -Force
$item | Add-Member -MemberType NoteProperty -Name "ImportTarget" -Value $match.Target -Force
}
try { $script:DGObjectsToImport.Items.Refresh() } catch { }
}
function Show-IntuneManagerImportForm
{
$policyTypes = Get-IntuneManagerSelectedPolicyTypes
if(($policyTypes | Measure-Object).Count -eq 0) {
$script:UIProvider.ShowMessageBox("No objects selected.", "Error", "OK", "Error")
return
}
$script:importForm = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\ImportForm.xaml"))
if(-not $script:importForm) { return }
Write-Status "Load import form"
$script:UIProvider.SetXamlProperty($script:importForm, "cbImportType", "ItemsSource", $script:IntuneManagerImportOptions)
$importSettings = [IntuneManagerImportSettings]::new()
$policyTypes | Add-IntuneManagerImportProperties -ImportSettings $importSettings
#!!!$policyTypes | Add-IntuneManagerImportUIExtensions -Form $script:importForm
$script:importForm.DataContext = $importSettings
$path = Get-SettingStoreValue "" "LastUsedFullPath"
if($path)
{
$di = [IO.DirectoryInfo]$path
if(($policyTypes | Where-Object { $_.Folder -eq $di.Name})) {
$path = $di.Parent.FullName
}
if([IO.Directory]::Exists($path) -eq $false)
{
$path = Get-SettingStoreValue "" "LastUsedRoot"
}
$importSettings.ImportFolder = $path
}
$script:DGObjectsToImport = $script:importForm.FindName("dgObjectsToImport")
$column = Get-GridCheckboxColumn "Selected"
$script:DGObjectsToImport.Columns.Add($column)
$column.Header.IsChecked = $true # All items are checked by default
$column.Header.add_Click({
foreach($Item in $script:DGObjectsToImport.ItemsSource)
{
$Item.Selected = $this.IsChecked
}
$script:DGObjectsToImport.Items.Refresh()
}
)
# Add Object type column
$binding = [System.Windows.Data.Binding]::new("Object.Name")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "Object Name"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:DGObjectsToImport.Columns.Add($column)
$binding = [System.Windows.Data.Binding]::new("Object.PolicyName")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "Policy Type"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:DGObjectsToImport.Columns.Add($column)
$binding = [System.Windows.Data.Binding]::new("Object.PolicyBaseName")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "Policy Base"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:DGObjectsToImport.Columns.Add($column)
$binding = [System.Windows.Data.Binding]::new("Object.Platform")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "Platform"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:DGObjectsToImport.Columns.Add($column)
$binding = [System.Windows.Data.Binding]::new("Object.FileInfo.Name")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "File Name"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:DGObjectsToImport.Columns.Add($column)
$binding = [System.Windows.Data.Binding]::new("ImportAction")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "Action"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:DGObjectsToImport.Columns.Add($column)
$binding = [System.Windows.Data.Binding]::new("ImportMatch")
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = "Match"
$column.IsReadOnly = $true
$column.Binding = $binding
$script:DGObjectsToImport.Columns.Add($column)
$script:UIProvider.AddXamlEvent($script:importForm, "cbImportType", "Add_SelectionChanged", {
if($script:importForm -and $script:importForm.DataContext) {
$selectedValue = $script:UIProvider.GetXamlProperty($script:importForm, "cbImportType", "SelectedValue")
if($selectedValue) { $script:importForm.DataContext.ImportType = $selectedValue }
}
Update-IntuneImportMatchInfo
})
$script:UIProvider.AddXamlEvent($script:importForm, "browseImportFolder", "add_click", {
$folder = Get-Folder ($script:UIProvider.GetXamlProperty($script:importForm, "txtImportFolder", "Text")) "Select root folder for import"
if($folder)
{
$script:UIProvider.SetXamlProperty($script:importForm, "txtImportFolder", "Text", $folder)
$script:importForm.DataContext.ImportFolder = $folder
Get-ImportPoliciesFromFolder $folder
}
})
$script:UIProvider.AddXamlEvent($script:importForm, "btnCancel", "add_click", {
$script:importForm = $null
Show-ModalObject
})
$script:UIProvider.AddXamlEvent($script:importForm, "btnImportSelected", "add_click", {
Write-Status "Import selected objects"
# Honour the ClearCacheBeforeExportImport setting (bit 8 = manual import).
Invoke-GraphCacheClearBeforeOperation -Operation ManualImport
$importType = $script:importForm.DataContext.ImportType
# Persist the per-import toggles so the import pipeline (Get-SettingValue) honours them.
$script:importForm.DataContext.Save()
$selectedPolicies = $script:DGObjectsToImport.ItemsSource | Where-Object Selected -eq $true
$policiesToImport = @()
$updatedPolicies = @()
Update-IntuneImportMatchInfo
foreach ($selectedPolicy in @($selectedPolicies))
{
$importPolicy = $selectedPolicy.Object
$match = Resolve-IntuneImportUpdateTarget -ImportPolicy $importPolicy -ExistingPolicies $script:dgIntuneManagerObjects.ItemsSource -SameTenant $script:importForm.DataContext.SameTenant -ImportType $importType
if($match.Action -eq "Update" -and $importType -eq "update")
{
$updatedPolicy = $importPolicy.UpdateObject($match.Target, (Get-DefaultTokenId))
if($updatedPolicy) { $updatedPolicies += $updatedPolicy }
}
elseif($match.Action -eq "Replace")
{
$replacedPolicy = Invoke-IntuneImportReplace -ImportPolicy $importPolicy -Target $match.Target -ImportType $importType
if($replacedPolicy) { $updatedPolicies += $replacedPolicy }
}
elseif($match.Action -eq "Ambiguous")
{
Write-Log "Skip import/update for $($importPolicy.Name) ($($importPolicy.PolicyName)): $($match.Message)" 2
}
elseif($match.Action -eq "Skip")
{
Write-Log "Skip import/update for $($importPolicy.Name) ($($importPolicy.PolicyName)): $($match.Message)"
}
else {
$policiesToImport += $importPolicy
}
}
$importedPolicies = $policiesToImport | Import-GraphPolicy
if($importedPolicies.Count -gt 0 -or $updatedPolicies.Count -gt 0) {
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
}
Show-ModalObject
Write-Status ""
})
$script:UIProvider.AddXamlEvent($script:importForm, "btnGetFiles", "add_click", {
# Used when the user manually updates the path and the press Get Files
Get-ImportPoliciesFromFolder $script:importForm.DataContext.ImportFolder
})
if($script:importForm.DataContext.ImportFolder)
{
Get-ImportPoliciesFromFolder $script:importForm.DataContext.ImportFolder -KeepStatus
}
$script:importForm.add_Loaded({
Write-Status ""
})
$script:UIProvider.ShowModalForm("Import objects", $script:importForm, $true)
}
function Get-ImportPoliciesFromFolder
{
param($Folder, [switch]$KeepStatus)
Write-Status "Get policy objects from $Folder"
$Folder = Expand-FileName $Folder
if([IO.Directory]::Exists($Folder) -eq $false) { return }
$params = @{}
$curPolicyTypes = Get-IntuneManagerSelectedPolicyTypes
$subFolders = @()
foreach($curPolicyType in $curPolicyTypes) {
if([IO.Directory]::Exists(([IO.Path]::Combine($Folder, $curPolicyType.Folder)))) {
$subFolders += $curPolicyType.Folder
}
}
if($subFolders.Count -gt 0) {
$params.Add("SubFolders", $subFolders)
}
if($curPolicyTypes) {
$params.Add("PolicyTypes", $curPolicyTypes)
}
#$params.Add("SearchSubFolders", $true) #!!!!
$items = @()
# !!! ToDo: Delete
#@(Get-PoliciesFromFile $folder @params) | ForEach-Object { $items += ([PSCustomObject]$_) }
@(Get-PoliciesFromFolder $folder @params) | ForEach-Object {
$policyObject = New-Object PSObject -Property @{
Selected = $true
Object = ([PSCustomObject]$_)
ImportAction = ""
ImportMatch = ""
ImportMatchStrategy = ""
ImportTarget = $null
}
$items += $policyObject
}
$script:DGObjectsToImport.ItemsSource = @($items | Sort-Object { $_.Object.Name })
Save-SettingStoreValue "" "LastUsedFullPath" $Folder
$migrationInfo, $sameTenant = Get-MigrationTableInfo $Folder $script:organizationId
$script:importForm.DataContext.SameTenant = [bool]$sameTenant
$script:UIProvider.SetXamlProperty($script:importForm, "chkReplaceDependencyIDs", "IsEnabled", ($sameTenant -eq $false))
$script:UIProvider.SetXamlProperty($script:importForm, "chkReplaceDependencyIDs", "IsChecked", ($sameTenant -eq $false))
$script:UIProvider.SetXamlProperty($script:importForm, "lblMigrationTableInfo", "Content", $migrationInfo)
Update-IntuneImportMatchInfo
if($KeepStatus -ne $true) {
Write-Status ""
}
}
<#
### ToDo: Delete !!!
function Get-PoliciesFromFile
{
param($Path, $Exclude = @("*_settings.json","*_assignments.json", "MigrationTable*.json"), $SelectedStatus = $true, $SubFolders = @(), [switch]$SearchSubFolders, [int]$Depth = 0)
if(-not $Path -or (Test-Path $Path -PathType Container) -eq $false) { return }
$params = @{}
if($Exclude)
{
$params.Add("Exclude", $Exclude)
}
if($SearchSubFolders -eq $true) {
$params.Add("Recurse", $true)
}
if($Depth -gt 0) {
$params.Add("Depth", $Depth)
}
$fileArr = @()
$policyTypes = Get-IntuneManagerSelectedPolicyTypes
$paths = @()
if($SubFolders.Count -gt 0) {
Get-ChildItem -path $Path | Where-Object { $_.Name -in $SubFolders -and $_ -is [IO.DirectoryInfo] } | ForEach-Object { $paths += $_.FullName }
}
else {
$paths += $path
}
foreach($policyPath in $paths) {
foreach($file in (Get-ChildItem -path "$policyPath\*.json" @params))
{
$graphObj = Get-GraphPolicyFromFile $file $policyTypes
if(-not $graphObj) { continue }
$policyObject = New-Object PSObject -Property @{
#FileName = $file.Name
#FileInfo = $file
Selected = $SelectedStatus
Object = ([PSCustomObject]$graphObj)
}
$fileArr += $policyObject
}
}
return @($fileArr)
}
#>
@@ -0,0 +1,118 @@
# Scoped policy picker (WPF).
#
# Thin wrapper over Show-ObjectPickerDialog: it supplies the tenant list, the
# scope list and a search handler, so the dialog queries Graph for the typed
# name inside the selected policy group / policy type instead of enumerating the
# whole tenant up front. The search itself lives in Internal/PolicySearch.ps1
# and is shared with the Avalonia backend (R10 - this file only collects input
# and renders).
function Show-PolicySearchDialog
{
param(
[string]$Title = "Select policy",
# Preselects this policy type in the "Search in" dropdown.
[string]$DefaultPolicyTypeId,
# Ids to keep out of the results (e.g. the policy being compared).
[string[]]$ExcludeIds,
# Rows to show before the first search. The compare flows seed this
# from the main view's already-loaded list so the dialog opens with
# something useful in it.
[object[]]$InitialItems
)
$scopes = Get-PolicySearchScopes -DefaultPolicyTypeId $DefaultPolicyTypeId
if(@($scopes).Count -eq 0) {
$script:UIProvider.ShowMessageBox("No policy types are available to search.", "Select policy", "OK", "Warning") | Out-Null
return $null
}
$defaultKey = Get-PolicySearchDefaultScopeKey -Scopes $scopes -PolicyTypeId $DefaultPolicyTypeId
# Empty unless more than one tenant is signed in; the dialog hides the combo
# in that case and every search runs against the default token.
$tenants = Get-PolicySearchTenants
$defaultTenantKey = Get-PolicySearchDefaultTenantKey -Tenants $tenants
$cols = @(
[PSCustomObject]@{ Header = "Name"; Binding = "Name" }
[PSCustomObject]@{ Header = "Type"; Binding = "PolicyType.Title" }
)
# Both handlers read $script: state rather than capturing locals - see the
# $script:_pickerState note in Show-ObjectPickerDialog.
$script:_policySearchState = @{
ExcludeIds = $ExcludeIds
MinLength = Get-PolicySearchMinLength
}
$searchHandler = {
param($SearchText, $Scope)
$st = $script:_policySearchState
if(-not $Scope) { return @() }
if([String]::IsNullOrWhiteSpace($SearchText) -or $SearchText.Trim().Length -lt $st.MinLength) {
Set-PickerStatusText "Type at least $($st.MinLength) characters, or use 'Load all in scope'."
return @()
}
# $null when the dialog is single-tenant, in which case the default
# token is used.
$tenant = Get-PickerSelectedTenant
$tokenId = if($tenant) { [int]$tenant.TokenId } else { Get-DefaultTokenId }
$where = if($tenant) { " in $($tenant.Title)" } else { "" }
Write-Status "Searching $($Scope.Title.Trim())$where..."
try {
$found = @(Search-IntunePolicies -Scope $Scope -SearchText $SearchText.Trim() -ExcludeIds $st.ExcludeIds -TokenId $tokenId)
if($found.Count -eq 0) {
Set-PickerStatusText "No object in $($Scope.Title.Trim())$where matches '$($SearchText.Trim())'."
}
else {
Set-PickerStatusText "$($found.Count) object(s) found$where."
}
return $found
}
finally {
Write-Status ""
}
}
$loadHandler = {
$st = $script:_policySearchState
$scope = Get-PickerSelectedScope
if(-not $scope) { return @() }
$tenant = Get-PickerSelectedTenant
$tokenId = if($tenant) { [int]$tenant.TokenId } else { Get-DefaultTokenId }
$where = if($tenant) { " in $($tenant.Title)" } else { "" }
Write-Status "Loading all objects in $($scope.Title.Trim())$where..."
try {
$loaded = @(Search-IntunePolicies -Scope $scope -ExcludeIds $st.ExcludeIds -TokenId $tokenId)
Set-PickerStatusText "$($loaded.Count) object(s) in $($scope.Title.Trim())$where."
return $loaded
}
finally {
Write-Status ""
}
}
try {
return Show-ObjectPickerDialog `
-Title $Title `
-Items $InitialItems `
-DisplayColumns $cols `
-Scopes $scopes `
-SelectedScopeKey $defaultKey `
-Tenants $tenants `
-SelectedTenantKey $defaultTenantKey `
-SearchHandler $searchHandler `
-LoadHandler $loadHandler `
-LoadLabel "Load all in scope"
}
finally {
$script:_policySearchState = $null
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
# Render parsed Markdown into a WPF TextBlock.
#
# The parse lives in Internal/MarkdownSimple.ps1 and is shared with Avalonia
# (R10); this file only turns blocks into WPF inlines, so the toolkit types stay
# inside UI/WPF (R12). The Avalonia counterpart is
# UI/Avalonia/Extensions/MarkdownRenderAvalonia.ps1 and is deliberately a
# near-mirror of this - same block handling, different inline construction.
#
# Everything goes into a single TextBlock rather than a document/panel per block:
# Run exposes FontSize, FontWeight, FontFamily and Background, so headings, bold
# spans, code and indented bullets all fit in one control, and the existing
# ScrollViewer keeps working unchanged.
function Set-WpfMarkdownText {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $TextBlock,
[string]$Markdown
)
if (-not $TextBlock) { return }
$TextBlock.Inlines.Clear()
if ([string]::IsNullOrEmpty($Markdown)) { return }
$baseSize = if ($TextBlock.FontSize -gt 0) { [double]$TextBlock.FontSize } else { 12.0 }
$style = Get-SimpleMarkdownStyle
$codeBrush = New-Object System.Windows.Media.SolidColorBrush ([System.Windows.Media.ColorConverter]::ConvertFromString($style.CodeBackground))
$codeFont = New-Object System.Windows.Media.FontFamily ($style.CodeFontFamily)
foreach ($block in (ConvertFrom-SimpleMarkdown $Markdown)) {
if ($block.Kind -eq 'Blank') {
$TextBlock.Inlines.Add((New-Object System.Windows.Documents.LineBreak))
continue
}
# Lead-in for list items and quotes. Leading whitespace in a Run is
# preserved, so indentation works without a panel per level.
$lead = switch ($block.Kind) {
'ListItem' { (' ' * ($block.Indent * 4)) + $block.Marker + ' ' }
'Quote' { $style.QuoteMarker }
default { $null }
}
if ($lead) {
$TextBlock.Inlines.Add((New-Object System.Windows.Documents.Run -ArgumentList $lead))
}
foreach ($inline in $block.Inlines) {
if ($inline.LineBreak) {
$TextBlock.Inlines.Add((New-Object System.Windows.Documents.LineBreak))
# Keep continuation lines of a bullet aligned under its text.
if ($block.Kind -eq 'ListItem') {
$pad = ' ' * (($block.Indent * 4) + $block.Marker.Length + 1)
$TextBlock.Inlines.Add((New-Object System.Windows.Documents.Run -ArgumentList $pad))
}
continue
}
# Links become real Hyperlinks; RequestNavigate opens the default
# browser, matching how the About and Welcome dialogs handle theirs.
if ($inline.Url) {
$link = New-Object System.Windows.Documents.Hyperlink
$link.Inlines.Add((New-Object System.Windows.Documents.Run -ArgumentList ([string]$inline.Text)))
try { $link.NavigateUri = New-Object System.Uri ($inline.Url) } catch { }
$link.Add_RequestNavigate({ Open-ExternalUri $_.Uri.AbsoluteUri; $_.Handled = $true })
if ($inline.Bold) { $link.FontWeight = [System.Windows.FontWeights]::Bold }
$TextBlock.Inlines.Add($link)
continue
}
$run = New-Object System.Windows.Documents.Run -ArgumentList ([string]$inline.Text)
if ($inline.Bold -or $block.Kind -eq 'Heading') {
$run.FontWeight = [System.Windows.FontWeights]::Bold
}
if ($block.Kind -eq 'Heading') {
$run.FontSize = Get-SimpleMarkdownHeadingSize -Level $block.Level -BaseSize $baseSize
}
if ($inline.Code -or $block.Kind -eq 'CodeBlock') {
$run.FontFamily = $codeFont
$run.Background = $codeBrush
}
if ($block.Kind -eq 'Quote') {
$run.FontStyle = [System.Windows.FontStyles]::Italic
}
$TextBlock.Inlines.Add($run)
}
$TextBlock.Inlines.Add((New-Object System.Windows.Documents.LineBreak))
}
}
+145
View File
@@ -0,0 +1,145 @@
# Text columns for WPF DataGrids: one line per row, capped length, pixel scrolling.
#
# A DataGridTextColumn renders every line of a multi-line value, so a store app
# description makes its row forty lines tall and turns the grid's row-based
# scrolling into a lurch. TextWrapping=NoWrap does not help: it only stops soft
# wrapping, and hard line breaks still render. And a single very long line has
# the opposite problem - it widens its column until the others are pushed out of
# view. FirstLineConverter trims the bound value to its first non-blank line,
# caps it at MaxLength characters (0 = no cap), appends an ellipsis when either
# happened, and with ConverterParameter "Full" returns the whole text only when
# something was cut - so the tooltip appears exactly on the cells that hide it.
if(-not ("FirstLineConverter" -as [type]))
{
# References resolved from loaded types, so the same call compiles on
# .NET Framework (PS5.1) and .NET (PS7).
Add-Type -ReferencedAssemblies @(
[System.Windows.Data.IValueConverter].Assembly.Location, # PresentationFramework
[System.Windows.DependencyObject].Assembly.Location, # WindowsBase
[System.Windows.Markup.MarkupExtension].Assembly.Location # System.Xaml - Binding.DoNothing needs it on .NET Framework
) -TypeDefinition @'
using System;
using System.Globalization;
using System.Windows.Data;
public class FirstLineConverter : IValueConverter
{
// Maximum characters kept from the first line. 0 = unlimited.
public int MaxLength { get; set; }
// First non-blank line, capped at maxLength; "\u2026" appended when any
// later non-blank line exists or the cap cut something off.
public static string FirstLine(string text, int maxLength, out bool trimmed)
{
trimmed = false;
if (text == null) return null;
string[] lines = text.Split(new[] { '\r', '\n' });
string first = null;
foreach (string line in lines)
{
string t = line.Trim();
if (t.Length == 0) continue;
if (first == null) { first = t; continue; }
trimmed = true;
break;
}
if (first == null) return string.Empty;
if (maxLength > 0 && first.Length > maxLength)
{
// Prefer a word boundary when one falls in the second half of the
// cap, so the cell ends "...built in" rather than "...with Cop".
int cut = first.LastIndexOf(' ', maxLength);
if (cut < maxLength / 2) cut = maxLength;
first = first.Substring(0, cut).TrimEnd();
trimmed = true;
}
return trimmed ? first + "\u2026" : first;
}
// A binding to a PowerShell SCRIPT property (the row's Description, Platform,
// LastModified ...) delivers the value wrapped in a PSObject; a note property
// (Object.description) delivers the raw string. Unwrap by reflection so the
// converter sees the string either way without referencing the PowerShell
// assembly - this is what left Description rendering all its lines while
// Object.description was trimmed.
private static object Unwrap(object value)
{
if (value == null) return null;
var t = value.GetType();
if (t.FullName != "System.Management.Automation.PSObject") return value;
var p = t.GetProperty("BaseObject");
return p == null ? value : p.GetValue(value, null);
}
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
value = Unwrap(value);
string text = value as string;
if (text == null) return value; // dates, numbers: leave the column's own formatting alone
bool trimmed;
string first = FirstLine(text, MaxLength, out trimmed);
if (parameter != null && string.Equals(parameter.ToString(), "Full", StringComparison.OrdinalIgnoreCase))
{
return trimmed ? text : null; // tooltip only where something was hidden
}
return first;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return Binding.DoNothing;
}
}
'@
}
# Builds a read-only text column. With -FirstLineOnly the cell shows the value's
# first line (capped at -MaxLength characters when that is above 0) and carries
# the full text as a tooltip. Sorting and the grid filter still use the
# underlying value: both read the binding path, not what is displayed.
function New-GridTextColumn
{
param(
[Parameter(Mandatory = $true)][string]$Path,
[string]$Header,
[switch]$FirstLineOnly,
[int]$MaxLength = 0
)
$column = [System.Windows.Controls.DataGridTextColumn]::new()
$column.Header = if($Header) { $Header } else { $Path.Split('.')[-1] }
$column.IsReadOnly = $true
$binding = [System.Windows.Data.Binding]::new($Path)
if($FirstLineOnly)
{
$binding.Converter = [FirstLineConverter]::new()
$binding.Converter.MaxLength = $MaxLength
$tooltip = [System.Windows.Data.Binding]::new($Path)
$tooltip.Converter = [FirstLineConverter]::new()
$tooltip.Converter.MaxLength = $MaxLength
$tooltip.ConverterParameter = 'Full'
$style = [System.Windows.Style]::new([System.Windows.Controls.TextBlock])
$style.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextTrimmingProperty, [System.Windows.TextTrimming]::CharacterEllipsis))
$style.Setters.Add([System.Windows.Setter]::new([System.Windows.FrameworkElement]::ToolTipProperty, $tooltip))
$column.ElementStyle = $style
}
$column.Binding = $binding
return $column
}
# Pixel-based scrolling for a DataGrid built in code; the XAML grids declare
# VirtualizingPanel.ScrollUnit="Pixel" directly. WPF's default scrolls by whole
# rows and snaps the top row to the edge, which lurches as soon as rows differ in
# height. Row virtualization stays on.
function Set-GridPixelScrolling
{
param([Parameter(Mandatory = $true)]$Grid)
[System.Windows.Controls.VirtualizingPanel]::SetScrollUnit($Grid, [System.Windows.Controls.ScrollUnit]::Pixel)
[System.Windows.Controls.VirtualizingPanel]::SetVirtualizationMode($Grid, [System.Windows.Controls.VirtualizationMode]::Recycling)
}
+17
View File
@@ -0,0 +1,17 @@
# Null-free array for anything assigned to a WPF ItemsSource.
#
# A $null element in an ItemsControl kills the host: WPF's layout and automation
# passes index the items, and both throw ArgumentNullException on a null one, on
# every render, until the process dies. @($null) is a ONE-element array containing
# null, so an omitted parameter or an empty result lands there via the usual @($x).
# Full write-up and the regression proof: Tests/UIItemsArray.Tests.ps1.
#
# The leading comma is load-bearing. A bare `return @(...)` is unrolled by the
# pipeline, so zero items would come back as $null and ONE item as the bare
# object - which cannot be assigned to an ItemsSource at all.
function Get-UIItemsArray
{
param($Items)
return ,@(@($Items) | Where-Object { $null -ne $_ })
}
+23
View File
@@ -0,0 +1,23 @@
# Menu icons load when a menu is built, not when the module is imported.
#
# ClassExtensions/IntuneCommonUIExtensions.ps1 attaches LoadIconImage/GetImage
# to every policy-type and group singleton at import time (the closures must
# capture the module-bound XAML loader then). It used to call LoadIconImage
# right away too, which parsed 82 icon files through XamlReader before Start.ps1
# had even reached Show-MainWindow - the whole pass ran before the splash
# existed. Show-ViewMenu calls this instead, once the splash is up and the menu
# actually needs the visuals. Each item keeps its own instance: a WPF visual can
# only have one parent.
function Initialize-MenuItemIcons
{
param($Items)
foreach($item in @($Items))
{
if($null -eq $item) { continue }
if($null -ne $item.IconImage) { continue }
if(-not $item.PSObject.Methods['LoadIconImage']) { continue }
try { $item.LoadIconImage() } catch { }
}
}