mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
IntuneManagement 4.0.0-beta1
This commit is contained in:
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 $_ })
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user