Files

429 lines
18 KiB
PowerShell

# Avalonia port of the Import dialog from UI/WPF/Extensions/IntuneManagerUI.ps1
# (Show-IntuneManagerImportForm + supporting helpers). New file because
# Import is a self-contained subsystem and the WPF original sits in the
# giant CoreUI/IntuneManagerUI files we're trying not to grow further
# (architecture rule R9).
#
# CLOSURE-FREE design (after 2026-05-30 fix): PowerShell's `.GetNewClosure()`
# puts a scriptblock into a brand-new DynamicModule that has no access to
# IntuneManagement's module-private functions (Save-SettingStoreValue,
# Get-MigrationTableInfo, Write-Log, ...). `ConvertTo-AvaloniaEventScriptBlock`
# then calls NewBoundScriptBlock which strips the closure entirely.
# Both effects = click handlers crash with "property not found" /
# "term not recognized". To avoid this, the form's state lives in
# `$script:_imImportFormState`, the helper logic is exposed as real
# module-scope functions (`Invoke-IntuneManagerImportLoad`,
# `Update-IntuneManagerImportMatch`), and click handlers are plain
# scriptblocks (no .GetNewClosure()) that reference the script-scope state
# and call the helper functions by name.
#
# The match-resolution helpers (Resolve-IntuneImportUpdateTarget,
# New-IntuneImportMatchResult, Get-IntuneImportPolicyReferenceTokens,
# Normalize-IntuneImportPolicyName) live in Internal/IntuneManager.ps1 and
# are shared with the WPF tree.
function Update-IntuneManagerImportMatch
{
# Operates on $script:_imImportFormState. Recomputes ImportAction /
# ImportMatch / ImportMatchStrategy / ImportTarget on every visible
# row, then forces a DataGrid refresh (no INotifyPropertyChanged on
# IntuneImportRowItem). Called from cbImportType selection-changed and
# from the load helper after a fresh load.
$st = $script:_imImportFormState
if (-not $st -or -not $st.DG -or -not $st.DG.ItemsSource) { return }
$importType = [string]$st.Settings.ImportType
$sameTenant = [bool]$st.Settings.SameTenant
$existing = @()
if ($script:dgIntuneManagerObjects) {
$existing = @($script:dgIntuneManagerObjects.ItemsSource | ForEach-Object {
if ($_ -and $_.Source) { $_.Source } else { $_ }
} | Where-Object { $_ })
}
foreach ($row in @($st.DG.ItemsSource)) {
if (-not $row -or -not $row.Source) { continue }
$match = Resolve-IntuneImportUpdateTarget -ImportPolicy $row.Source -ExistingPolicies $existing -SameTenant $sameTenant -ImportType $importType
$row.ImportAction = [string]$match.Action
$row.ImportMatch = [string]$match.Message
$row.ImportMatchStrategy = [string]$match.Strategy
$row.ImportTarget = $match.Target
}
$current = @($st.DG.ItemsSource)
$st.DG.ItemsSource = $null
$st.DG.ItemsSource = $current
}
function Invoke-IntuneManagerImportLoad
{
# Reads $script:_imImportFormState. Loads files from $Folder into
# the form's DataGrid + populates migration-table info. Surface any
# error via message box so the user sees the cause; the previous
# silent-return path swallowed Get-MigrationTableInfo / Get-PoliciesFromFolder
# failures.
param([string]$Folder, [bool]$KeepStatus, [switch]$Quiet)
$st = $script:_imImportFormState
if (-not $st) { return }
$ui = $script:UIProvider
if (-not $Folder) { return }
Write-Status "Get policy objects from $Folder"
try { $Folder = Expand-FileName $Folder } catch { }
if (-not [IO.Directory]::Exists($Folder)) {
if (-not $KeepStatus) { Write-Status "" }
# -Quiet is used for the seeded-folder load that runs BEFORE the dialog
# is shown: a popup there appears out of nowhere with no dialog behind
# it. An explicit Browse/Get files still warns.
if (-not $Quiet) {
$ui.ShowMessageBox("Folder does not exist:`n$Folder", "Import", "OK", "Warning")
} else {
Write-Log "Import: seeded folder does not exist: $Folder" 2
}
return
}
try {
$params = @{}
$curPolicyTypes = Get-IntuneManagerSelectedPolicyTypes
$subFolders = @()
foreach ($pt in $curPolicyTypes) {
if ([IO.Directory]::Exists([IO.Path]::Combine($Folder, $pt.Folder))) {
$subFolders += $pt.Folder
}
}
if ($subFolders.Count -gt 0) { $params['SubFolders'] = $subFolders }
if ($curPolicyTypes) { $params['PolicyTypes'] = $curPolicyTypes }
# Collect into a typed List + sort in place. Routing through
# `Sort-Object ObjectName` would stamp a PSObject ETS wrapper on each
# IntuneImportRowItem and Avalonia's DataGrid text-column binder
# reflects on the runtime type and renders blank cells against the
# wrapper (see [[avalonia-binding-needs-clr-types]]).
$rows = [System.Collections.Generic.List[IntuneImportRowItem]]::new()
foreach ($policy in @(Get-PoliciesFromFolder $Folder @params)) {
$obj = [PSCustomObject]$policy
$row = [IntuneImportRowItem]::new()
$row.Selected = $true
$row.Source = $obj
$row.ObjectName = [string]$obj.Name
$row.PolicyType = [string]$obj.PolicyName
$row.PolicyBase = [string]$obj.PolicyBaseName
$row.Platform = [string]$obj.Platform
$row.FileName = if ($obj.FileInfo) { [string]$obj.FileInfo.Name } else { '' }
[void]$rows.Add($row)
}
$rows.Sort([Comparison[IntuneImportRowItem]]{
param($a, $b)
[string]::Compare($a.ObjectName, $b.ObjectName, [StringComparison]::OrdinalIgnoreCase)
})
if ($st.DG) { $st.DG.ItemsSource = $rows }
Save-SettingStoreValue "" "LastUsedFullPath" $Folder
$migrationInfo, $sameTenant = Get-MigrationTableInfo $Folder $script:organizationId
$st.Settings.SameTenant = [bool]$sameTenant
if ($st.LblMigInfo) { $st.LblMigInfo.Text = [string]$migrationInfo }
if ($st.ChkRepl) {
$st.ChkRepl.IsEnabled = (-not $sameTenant)
$st.ChkRepl.IsChecked = (-not $sameTenant)
$st.Settings.ReplaceDependencyIDs = (-not $sameTenant)
}
Update-IntuneManagerImportMatch
} catch {
# Log the internals; show the user only the message. The raw
# script/line dump had no WPF counterpart.
Write-LogError "Invoke-IntuneManagerImportLoad failed" $_.Exception
$ui.ShowMessageBox("Could not read the import folder:`n`n$($_.Exception.Message)", "Import error", "OK", "Error")
}
if (-not $KeepStatus) { Write-Status "" }
}
function Show-IntuneManagerImportForm
{
$ui = $script:UIProvider
$policyTypes = Get-IntuneManagerSelectedPolicyTypes
if (($policyTypes | Measure-Object).Count -eq 0) {
$ui.ShowMessageBox("No object types selected.", "Error", "OK", "Error")
return
}
$importForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/ImportForm.axaml'))
if (-not $importForm) { return }
Write-Status "Load import form"
$hostType = Get-AvaloniaHost
$txtFolder = $hostType::FindByName($importForm, 'txtImportFolder')
$browseFolder = $hostType::FindByName($importForm, 'browseImportFolder')
$lblMigInfo = $hostType::FindByName($importForm, 'lblMigrationTableInfo')
$chkScopes = $hostType::FindByName($importForm, 'chkImportScopes')
$chkAssign = $hostType::FindByName($importForm, 'chkImportAssignments')
$chkReplaceDeps = $hostType::FindByName($importForm, 'chkReplaceDependencyIDs')
$cbImportType = $hostType::FindByName($importForm, 'cbImportType')
$dgImport = $hostType::FindByName($importForm, 'dgObjectsToImport')
$btnGetFiles = $hostType::FindByName($importForm, 'btnGetFiles')
$btnImport = $hostType::FindByName($importForm, 'btnImportSelected')
$btnCancel = $hostType::FindByName($importForm, 'btnCancel')
# Settings instance + per-type extras (data-side hooks).
$importSettings = [IntuneManagerImportSettings]::new()
$policyTypes | Add-IntuneManagerImportProperties -ImportSettings $importSettings
$policyTypes | Add-IntuneManagerImportUIExtensions -Form $importForm
# Prefer the last-used full path, but if it points into a per-type subfolder
# of the currently-selected policy types, climb up one level so the parent
# is used (matches WPF heuristic — recommended behaviour for multi-type
# imports).
$path = Get-SettingStoreValue "" "LastUsedFullPath"
if ($path) {
try {
$di = [IO.DirectoryInfo]$path
if ($policyTypes | Where-Object { $_.Folder -eq $di.Name }) {
$path = $di.Parent.FullName
}
if (-not [IO.Directory]::Exists($path)) {
$path = Get-SettingStoreValue "" "LastUsedRoot"
}
} catch { }
if ($path) { $importSettings.ImportFolder = $path }
}
if ($txtFolder) { $txtFolder.Text = [string]$importSettings.ImportFolder }
if ($chkScopes) { $chkScopes.IsChecked = [bool]$importSettings.ImportScopeTags }
if ($chkAssign) { $chkAssign.IsChecked = [bool]$importSettings.ImportAssignments }
if ($chkReplaceDeps) { $chkReplaceDeps.IsChecked = [bool]$importSettings.ReplaceDependencyIDs }
# Import type ComboBox: NameValueObject -> SettingsListItem.
$importTypeItems = @()
foreach ($opt in $script:IntuneManagerImportOptions) {
$importTypeItems += [SettingsListItem]@{
Name = [string]$opt.Name
Value = [string]$opt.Value
}
}
if ($cbImportType) {
$cbImportType.ItemsSource = $importTypeItems
$current = $importTypeItems | Where-Object { $_.Value -eq [string]$importSettings.ImportType } | Select-Object -First 1
if (-not $current -and $importTypeItems.Count -gt 0) { $current = $importTypeItems[0] }
if ($current) { $cbImportType.SelectedItem = $current }
}
# Module-scope state container. Avoids .GetNewClosure() in event
# handlers (see file-level comment). Cleared when the form closes.
$script:_imImportFormState = @{
Settings = $importSettings
Form = $importForm
DG = $dgImport
LblMigInfo = $lblMigInfo
ChkRepl = $chkReplaceDeps
TxtFolder = $txtFolder
Confirmed = $false
}
# --- Event wiring (NO closures — references $script:_imImportFormState by name) ---
if ($browseFolder) {
$browseFolder.add_Click({
$st = $script:_imImportFormState
$ui2 = $script:UIProvider
if (-not $st) { return }
$start = if ($st.TxtFolder) { [string]$st.TxtFolder.Text } else { $null }
$folder = $ui2.ShowFolderPicker($start, 'Select root folder for import')
if ($folder) {
if ($st.TxtFolder) { $st.TxtFolder.Text = $folder }
$st.Settings.ImportFolder = $folder
Invoke-IntuneManagerImportLoad -Folder $folder -KeepStatus $false
}
})
}
if ($btnGetFiles) {
$btnGetFiles.add_Click({
$st = $script:_imImportFormState
if (-not $st) { return }
$folder = if ($st.TxtFolder) { [string]$st.TxtFolder.Text } else { $st.Settings.ImportFolder }
$st.Settings.ImportFolder = $folder
Invoke-IntuneManagerImportLoad -Folder $folder -KeepStatus $false
})
}
if ($cbImportType) {
$cbImportType.add_SelectionChanged({
param($s, $e)
$st = $script:_imImportFormState
if (-not $st) { return }
if ($s.SelectedItem) {
$st.Settings.ImportType = [string]$s.SelectedItem.Value
}
Update-IntuneManagerImportMatch
})
}
# Two-way mirror for the simple checkboxes.
if ($chkScopes) {
$chkScopes.add_IsCheckedChanged({
param($s, $e)
$st = $script:_imImportFormState
if ($st) { $st.Settings.ImportScopeTags = [bool]$s.IsChecked }
})
}
if ($chkAssign) {
$chkAssign.add_IsCheckedChanged({
param($s, $e)
$st = $script:_imImportFormState
if ($st) { $st.Settings.ImportAssignments = [bool]$s.IsChecked }
})
}
if ($chkReplaceDeps) {
$chkReplaceDeps.add_IsCheckedChanged({
param($s, $e)
$st = $script:_imImportFormState
if ($st) { $st.Settings.ReplaceDependencyIDs = [bool]$s.IsChecked }
})
}
# Select/deselect-all moved into the DataGrid column header.
Initialize-AvaloniaGridSelectAllHeader -Grid $dgImport -BindingProperty 'Selected' -InitiallyChecked $true | Out-Null
if ($btnImport) {
$btnImport.add_Click({
# Run the import while the form state is still live, then close + clear.
$st = $script:_imImportFormState
if (-not $st) { return }
try { Invoke-IntuneManagerImportExecute }
catch { Write-LogError "Import execution failed" $_.Exception }
Show-ModalObject
$script:_imImportFormState = $null
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
})
}
if ($btnCancel) {
$btnCancel.add_Click({
Show-ModalObject
$script:_imImportFormState = $null
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
})
}
$importForm.add_KeyDown({
param($s, $e)
if ($e.Key -eq [Avalonia.Input.Key]::Escape) {
Show-ModalObject
$script:_imImportFormState = $null
$e.Handled = $true
}
})
# Initial load against the seeded folder (if any). KeepStatus=true so
# Write-Status "" runs after the modal opens, not before.
if ($importSettings.ImportFolder) {
Invoke-IntuneManagerImportLoad -Folder $importSettings.ImportFolder -KeepStatus $true -Quiet
}
# ShowModalForm is non-blocking, so the import cannot run after it returns
# (the handlers fire later, on the dispatcher loop). It runs inside the
# btnImport handler via Invoke-IntuneManagerImportExecute while state is live.
$ui.ShowModalForm("Import objects", $importForm, $true)
Write-Status ""
}
function Invoke-IntuneManagerImportExecute
{
# Runs the actual import for the live $script:_imImportFormState. Called from
# the Import button handler (not after ShowModalForm, which does not block).
$st = $script:_imImportFormState
if (-not $st) { return }
$ui = $script:UIProvider
Write-Status "Import selected objects"
# Honour the ClearCacheBeforeExportImport setting (bit 8 = manual import).
Invoke-GraphCacheClearBeforeOperation -Operation ManualImport
$importType = [string]$st.Settings.ImportType
# Persist the per-import toggles so the import pipeline (Get-SettingValue) honours them.
$st.Settings.Save()
$rows = @()
if ($st.DG -and $st.DG.ItemsSource) {
$rows = @($st.DG.ItemsSource | Where-Object { $_ -and $_.Selected })
}
if ($rows.Count -eq 0) {
Write-Log "No files selected for import" 2
Write-Status ""
return
}
# Re-resolve match info up to the moment of import — the user may have
# toggled rows or changed ImportType after the last refresh. Put state
# back briefly so the helper can read it.
$script:_imImportFormState = $st
Update-IntuneManagerImportMatch
$script:_imImportFormState = $null
$existing = @()
if ($script:dgIntuneManagerObjects) {
$existing = @($script:dgIntuneManagerObjects.ItemsSource | ForEach-Object {
if ($_ -and $_.Source) { $_.Source } else { $_ }
} | Where-Object { $_ })
}
$policiesToImport = @()
$updatedPolicies = @()
foreach ($row in $rows) {
$importPolicy = $row.Source
if (-not $importPolicy) { continue }
$match = Resolve-IntuneImportUpdateTarget -ImportPolicy $importPolicy -ExistingPolicies $existing -SameTenant $st.Settings.SameTenant -ImportType $importType
if ($match.Action -eq "Update" -and $importType -eq "update") {
try {
$updated = $importPolicy.UpdateObject($match.Target, (Get-DefaultTokenId))
if ($updated) { $updatedPolicies += $updated }
} catch { Write-LogError "UpdateObject failed for $($importPolicy.Name)" $_.Exception }
}
elseif ($match.Action -eq "Replace") {
try {
$replaced = Invoke-IntuneImportReplace -ImportPolicy $importPolicy -Target $match.Target -ImportType $importType
if ($replaced) { $updatedPolicies += $replaced }
} catch { Write-LogError "Replace failed for $($importPolicy.Name)" $_.Exception }
}
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 = @()
if ($policiesToImport.Count -gt 0) {
try {
$importedPolicies = @($policiesToImport | Import-GraphPolicy)
} catch {
Write-LogError "Import-GraphPolicy failed" $_.Exception
$ui.ShowMessageBox("Import failed: $($_.Exception.Message)", "Error", "OK", "Error")
Write-Status ""
return
}
}
if ($importedPolicies.Count -gt 0 -or $updatedPolicies.Count -gt 0) {
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject | Out-Null
}
Write-Status ""
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
}